mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-22 11:45:59 +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:
parent
8f437dc773
commit
8c78959aaf
@ -1,9 +1,12 @@
|
||||
import { IntegrationContext } from "./integration-context";
|
||||
import { IntegrationMetadata } from "./integration-metadata";
|
||||
import { ApiSettings, TokenHeader } from "./rpc";
|
||||
import { ApiSettings, IntegrationRequest, TokenHeader } from "./rpc";
|
||||
|
||||
/** Configures integration-wide settings */
|
||||
export type IntegrationConfiguration = IntegrationMetadata & {
|
||||
/** Creates the authentication header for all integration remote procedure calls */
|
||||
authenticate: (settings: ApiSettings, context: IntegrationContext) => TokenHeader;
|
||||
authenticate: (
|
||||
request: IntegrationRequest,
|
||||
context: IntegrationContext<ApiSettings>,
|
||||
) => TokenHeader;
|
||||
};
|
||||
|
@ -25,7 +25,7 @@ describe("IntegrationContext", () => {
|
||||
|
||||
describe("baseUrl", () => {
|
||||
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();
|
||||
|
||||
@ -41,15 +41,15 @@ describe("IntegrationContext", () => {
|
||||
};
|
||||
i18n.t.mockReturnValue("error");
|
||||
|
||||
const context = new IntegrationContext(noBaseUrl, i18n);
|
||||
const context = new IntegrationContext(noBaseUrl, null, i18n);
|
||||
|
||||
expect(() => context.baseUrl()).toThrow("error");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
@ -62,9 +62,9 @@ describe("IntegrationContext", () => {
|
||||
baseUrl: "example.com",
|
||||
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");
|
||||
});
|
||||
@ -77,11 +77,22 @@ describe("IntegrationContext", () => {
|
||||
baseUrl: "example.com",
|
||||
selfHost: "always",
|
||||
};
|
||||
const context = new IntegrationContext(selfHostAlways, i18n);
|
||||
const context = new IntegrationContext(selfHostAlways, { baseUrl: "http.bin" }, i18n);
|
||||
|
||||
// expect success
|
||||
const result = context.baseUrl({ baseUrl: "http.bin" });
|
||||
const result = context.baseUrl();
|
||||
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
|
||||
i18n.t.mockReturnValue("error");
|
||||
@ -97,7 +108,7 @@ describe("IntegrationContext", () => {
|
||||
selfHost: "maybe",
|
||||
};
|
||||
|
||||
const context = new IntegrationContext(selfHostMaybe, i18n);
|
||||
const context = new IntegrationContext(selfHostMaybe, null, i18n);
|
||||
|
||||
const result = context.baseUrl();
|
||||
|
||||
@ -113,9 +124,9 @@ describe("IntegrationContext", () => {
|
||||
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");
|
||||
});
|
||||
@ -123,39 +134,47 @@ describe("IntegrationContext", () => {
|
||||
|
||||
describe("authenticationToken", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
it("base64 encodes the read value", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
||||
it("suffix is appended to the token", () => {
|
||||
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==");
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
expect(() => context.authenticationToken({})).toThrow("error");
|
||||
expect(() => context.authenticationToken()).toThrow("error");
|
||||
});
|
||||
|
||||
it("throws an error when the value is empty", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
||||
it.each([[undefined], [null], [""]])("throws an error when the value is %p", (token) => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, { token }, i18n);
|
||||
i18n.t.mockReturnValue("error");
|
||||
|
||||
expect(() => context.authenticationToken({ token: "" })).toThrow("error");
|
||||
expect(() => context.authenticationToken()).toThrow("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("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" });
|
||||
|
||||
@ -163,7 +182,7 @@ describe("IntegrationContext", () => {
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
@ -173,7 +192,7 @@ describe("IntegrationContext", () => {
|
||||
|
||||
describe("generatedBy", () => {
|
||||
it("creates generated by text", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
||||
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
|
||||
i18n.t.mockReturnValue("result");
|
||||
|
||||
const result = context.generatedBy({ website: null });
|
||||
@ -183,7 +202,7 @@ describe("IntegrationContext", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
const result = context.generatedBy({ website: "www.example.com" });
|
||||
|
@ -2,30 +2,33 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { IntegrationMetadata } from "./integration-metadata";
|
||||
import { ApiSettings, SelfHostedApiSettings, IntegrationRequest } from "./rpc";
|
||||
import { ApiSettings, IntegrationRequest } from "./rpc";
|
||||
|
||||
/** Utilities for processing integration settings */
|
||||
export class IntegrationContext {
|
||||
export class IntegrationContext<Settings extends object> {
|
||||
/** Instantiates an integration context
|
||||
* @param metadata - defines integration capabilities
|
||||
* @param i18n - localizes error messages
|
||||
*/
|
||||
constructor(
|
||||
readonly metadata: IntegrationMetadata,
|
||||
protected settings: Settings,
|
||||
protected i18n: I18nService,
|
||||
) {}
|
||||
|
||||
/** Lookup the integration's baseUrl
|
||||
* @param settings settings that override the baseUrl.
|
||||
* @returns the baseUrl for the API's integration point.
|
||||
* - By default this is defined by the metadata
|
||||
* - 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
|
||||
* supplied by an argument.
|
||||
*/
|
||||
baseUrl(settings?: SelfHostedApiSettings) {
|
||||
baseUrl(): string {
|
||||
// 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 = "";
|
||||
|
||||
// look up definition
|
||||
@ -47,18 +50,24 @@ export class IntegrationContext {
|
||||
}
|
||||
|
||||
/** 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.suffix a string to append to the token. Defaults to empty.
|
||||
* @returns the user's authentication token
|
||||
* @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) {
|
||||
if (!settings.token || settings.token === "") {
|
||||
authenticationToken(
|
||||
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);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let token = settings.token;
|
||||
// if a suffix exists, it needs to be included before encoding
|
||||
token += options?.suffix ?? "";
|
||||
if (options?.base64) {
|
||||
token = Utils.fromUtf8ToB64(token);
|
||||
}
|
||||
|
@ -51,17 +51,41 @@ describe("RestClient", () => {
|
||||
expect(api.nativeFetch).toHaveBeenCalledWith(expectedRpc.fetchRequest);
|
||||
});
|
||||
|
||||
it.each([[401], [403]])(
|
||||
it.each([[401] /*,[403]*/])(
|
||||
"throws an invalid token error when HTTP status is %i",
|
||||
async (status) => {
|
||||
const client = new RestClient(api, i18n);
|
||||
const request: IntegrationRequest = { website: null };
|
||||
const response = mock<Response>({ status });
|
||||
const response = mock<Response>({ status, statusText: null });
|
||||
api.nativeFetch.mockResolvedValue(response);
|
||||
|
||||
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);
|
||||
|
||||
await expect(result).rejects.toEqual("forwarderInvalidTokenWithMessage");
|
||||
await expect(result).rejects.toEqual("forwaderInvalidTokenWithMessage");
|
||||
expect(i18n.t).toHaveBeenCalledWith(
|
||||
"forwarderInvalidTokenWithMessage",
|
||||
"forwaderInvalidTokenWithMessage",
|
||||
"mock",
|
||||
"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",
|
||||
async (status) => {
|
||||
const client = new RestClient(api, i18n);
|
||||
@ -108,8 +189,10 @@ describe("RestClient", () => {
|
||||
);
|
||||
|
||||
it.each([
|
||||
[429, "message"],
|
||||
[500, "message"],
|
||||
[500, "message"],
|
||||
[429, "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 () => {
|
||||
const client = new RestClient(api, i18n);
|
||||
rpc.hasJsonPayload.mockReturnValue(false);
|
||||
|
@ -10,59 +10,96 @@ export class RestClient {
|
||||
private api: ApiService,
|
||||
private i18n: I18nService,
|
||||
) {}
|
||||
|
||||
/** uses the fetch API to request a JSON payload. */
|
||||
async fetchJson<Parameters extends IntegrationRequest, Response>(
|
||||
rpc: JsonRpc<Parameters, Response>,
|
||||
// FIXME: once legacy password generator is removed, replace forwarder-specific error
|
||||
// messages with RPC-generalized ones.
|
||||
async fetchJson<Parameters extends IntegrationRequest, Result>(
|
||||
rpc: JsonRpc<Parameters, Result>,
|
||||
params: Parameters,
|
||||
): Promise<Response> {
|
||||
): Promise<Result> {
|
||||
// run the request
|
||||
const request = rpc.toRequest(params);
|
||||
const response = await this.api.nativeFetch(request);
|
||||
|
||||
// FIXME: once legacy password generator is removed, replace forwarder-specific error
|
||||
// messages with RPC-generalized ones.
|
||||
let error: string = undefined;
|
||||
let cause: string = undefined;
|
||||
let result: Result = undefined;
|
||||
let errorKey: string = undefined;
|
||||
let errorMessage: 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) {
|
||||
cause = await this.tryGetErrorMessage(response);
|
||||
error = cause ? "forwarderInvalidTokenWithMessage" : "forwarderInvalidToken";
|
||||
} else if (response.status >= 500) {
|
||||
cause = await this.tryGetErrorMessage(response);
|
||||
cause = cause ?? response.statusText;
|
||||
error = "forwarderError";
|
||||
const message = await this.tryGetErrorMessage(response);
|
||||
const key = message ? "forwaderInvalidTokenWithMessage" : "forwaderInvalidToken";
|
||||
return [key, message];
|
||||
} else if (response.status === 429 || response.status >= 500) {
|
||||
const message = await this.tryGetErrorMessage(response);
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
const json = JSON.parse(body);
|
||||
if ("error" in json) {
|
||||
return json.error;
|
||||
} else if ("message" in json) {
|
||||
return json.message;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
37
libs/common/src/tools/state/classifier.ts
Normal file
37
libs/common/src/tools/state/classifier.ts
Normal 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>;
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Classifier } from "./classifier";
|
||||
|
||||
/** Classifies an object's JSON-serializable data by property into
|
||||
* 3 categories:
|
||||
* * 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
|
||||
* 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(
|
||||
disclosed: readonly (keyof Jsonify<Disclosed> & keyof Jsonify<Plaintext>)[],
|
||||
excluded: readonly (keyof Plaintext)[],
|
||||
|
@ -1,15 +1,19 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { GENERATOR_DISK, UserKeyDefinitionOptions } from "../../platform/state";
|
||||
|
||||
import { SecretClassifier } from "./secret-classifier";
|
||||
import { Classifier } from "./classifier";
|
||||
import { SecretKeyDefinition } from "./secret-key-definition";
|
||||
|
||||
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: [] };
|
||||
|
||||
it("toEncryptedStateKey returns a key", () => {
|
||||
const expectedOptions: UserKeyDefinitionOptions<any> = {
|
||||
deserializer: (v: any) => v,
|
||||
const expectedOptions: UserKeyDefinitionOptions<TestData> = {
|
||||
deserializer: (v: Jsonify<TestData>) => v,
|
||||
cleanupDelayMs: 100,
|
||||
clearOn: ["logout", "lock"],
|
||||
};
|
||||
|
@ -2,7 +2,7 @@ import { UserKeyDefinitionOptions, UserKeyDefinition } from "../../platform/stat
|
||||
// eslint-disable-next-line -- `StateDefinition` used as an argument
|
||||
import { StateDefinition } from "../../platform/state/state-definition";
|
||||
import { ClassifiedFormat } from "./classified-format";
|
||||
import { SecretClassifier } from "./secret-classifier";
|
||||
import { Classifier } from "./classifier";
|
||||
|
||||
/** 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(
|
||||
readonly stateDefinition: StateDefinition,
|
||||
readonly key: string,
|
||||
readonly classifier: SecretClassifier<Inner, Disclosed, Secret>,
|
||||
readonly classifier: Classifier<Inner, Disclosed, Secret>,
|
||||
readonly options: UserKeyDefinitionOptions<Inner>,
|
||||
// type erasure is necessary here because typescript doesn't support
|
||||
// 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>(
|
||||
stateDefinition: StateDefinition,
|
||||
key: string,
|
||||
classifier: SecretClassifier<Value, Disclosed, Secret>,
|
||||
classifier: Classifier<Value, Disclosed, Secret>,
|
||||
options: UserKeyDefinitionOptions<Value>,
|
||||
) {
|
||||
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>(
|
||||
stateDefinition: StateDefinition,
|
||||
key: string,
|
||||
classifier: SecretClassifier<Item, Disclosed, Secret>,
|
||||
classifier: Classifier<Item, Disclosed, Secret>,
|
||||
options: UserKeyDefinitionOptions<Item>,
|
||||
) {
|
||||
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>(
|
||||
stateDefinition: StateDefinition,
|
||||
key: string,
|
||||
classifier: SecretClassifier<Item, Disclosed, Secret>,
|
||||
classifier: Classifier<Item, Disclosed, Secret>,
|
||||
options: UserKeyDefinitionOptions<Item>,
|
||||
) {
|
||||
return new SecretKeyDefinition<Record<Id, Item>, Id, Item, Disclosed, Secret>(
|
||||
|
@ -14,5 +14,6 @@ export * from "./default-simple-login-options";
|
||||
export * from "./disabled-passphrase-generator-policy";
|
||||
export * from "./disabled-password-generator-policy";
|
||||
export * from "./forwarders";
|
||||
export * from "./integrations";
|
||||
export * from "./policies";
|
||||
export * from "./username-digits";
|
||||
|
15
libs/tools/generator/core/src/data/integrations.ts
Normal file
15
libs/tools/generator/core/src/data/integrations.ts
Normal 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);
|
@ -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>;
|
||||
};
|
||||
};
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
63
libs/tools/generator/core/src/engine/forwarder-context.ts
Normal file
63
libs/tools/generator/core/src/engine/forwarder-context.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -1,4 +1,7 @@
|
||||
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 { EmailCalculator } from "./email-calculator";
|
||||
export { PasswordRandomizer } from "./password-randomizer";
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
41
libs/tools/generator/core/src/engine/rpc/get-account-id.ts
Normal file
41
libs/tools/generator/core/src/engine/rpc/get-account-id.ts
Normal 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;
|
||||
}
|
||||
}
|
2
libs/tools/generator/core/src/engine/rpc/index.ts
Normal file
2
libs/tools/generator/core/src/engine/rpc/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./create-forwarding-address";
|
||||
export * from "./get-account-id";
|
20
libs/tools/generator/core/src/engine/settings.ts
Normal file
20
libs/tools/generator/core/src/engine/settings.ts
Normal 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;
|
||||
};
|
@ -2,6 +2,7 @@ export * from "./abstractions";
|
||||
export * from "./data";
|
||||
export { createRandomizer } from "./factories";
|
||||
export * as engine from "./engine";
|
||||
export * as integration from "./integration";
|
||||
export * as policies from "./policies";
|
||||
export * as rx from "./rx";
|
||||
export * as services from "./services";
|
||||
|
83
libs/tools/generator/core/src/integration/addy-io.spec.ts
Normal file
83
libs/tools/generator/core/src/integration/addy-io.spec.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
73
libs/tools/generator/core/src/integration/addy-io.ts
Normal file
73
libs/tools/generator/core/src/integration/addy-io.ts
Normal 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);
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
61
libs/tools/generator/core/src/integration/duck-duck-go.ts
Normal file
61
libs/tools/generator/core/src/integration/duck-duck-go.ts
Normal 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);
|
215
libs/tools/generator/core/src/integration/fastmail.spec.ts
Normal file
215
libs/tools/generator/core/src/integration/fastmail.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
127
libs/tools/generator/core/src/integration/fastmail.ts
Normal file
127
libs/tools/generator/core/src/integration/fastmail.ts
Normal 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);
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
69
libs/tools/generator/core/src/integration/firefox-relay.ts
Normal file
69
libs/tools/generator/core/src/integration/firefox-relay.ts
Normal 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);
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
76
libs/tools/generator/core/src/integration/forward-email.ts
Normal file
76
libs/tools/generator/core/src/integration/forward-email.ts
Normal 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);
|
6
libs/tools/generator/core/src/integration/index.ts
Normal file
6
libs/tools/generator/core/src/integration/index.ts
Normal 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";
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
74
libs/tools/generator/core/src/integration/simple-login.ts
Normal file
74
libs/tools/generator/core/src/integration/simple-login.ts
Normal 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);
|
@ -6,7 +6,7 @@ import { DefaultCatchallOptions } from "../data";
|
||||
import { EmailCalculator, EmailRandomizer } from "../engine";
|
||||
import { newDefaultEvaluator } from "../rx";
|
||||
import { NoPolicy, CatchallGenerationOptions } from "../types";
|
||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
||||
import { observe$PerUserId, sharedStateByUserId } from "../util";
|
||||
|
||||
import { CATCHALL_SETTINGS } from "./storage";
|
||||
|
||||
@ -26,7 +26,7 @@ export class CatchallGeneratorStrategy
|
||||
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(CATCHALL_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
||||
defaults$ = observe$PerUserId(() => this.defaultOptions);
|
||||
toEvaluator = newDefaultEvaluator<CatchallGenerationOptions>();
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
|
||||
|
@ -6,7 +6,7 @@ import { DefaultEffUsernameOptions, UsernameDigits } from "../data";
|
||||
import { UsernameRandomizer } from "../engine";
|
||||
import { newDefaultEvaluator } from "../rx";
|
||||
import { EffUsernameGenerationOptions, NoPolicy } from "../types";
|
||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
||||
import { observe$PerUserId, sharedStateByUserId } from "../util";
|
||||
|
||||
import { EFF_USERNAME_SETTINGS } from "./storage";
|
||||
|
||||
@ -25,7 +25,7 @@ export class EffUsernameGeneratorStrategy
|
||||
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(EFF_USERNAME_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
||||
defaults$ = observe$PerUserId(() => this.defaultOptions);
|
||||
toEvaluator = newDefaultEvaluator<EffUsernameGenerationOptions>();
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
|
||||
|
@ -7,41 +7,17 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { 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 { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../common/spec";
|
||||
import { DefaultDuckDuckGoOptions } from "../data";
|
||||
import { AddyIo, Fastmail, FirefoxRelay } from "../integration";
|
||||
import { DefaultPolicyEvaluator } from "../policies";
|
||||
import { ApiOptions } from "../types";
|
||||
|
||||
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 AnotherUser = "another user" as UserId;
|
||||
@ -56,6 +32,8 @@ describe("ForwarderGeneratorStrategy", () => {
|
||||
const encryptService = mock<EncryptService>();
|
||||
const keyService = mock<CryptoService>();
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const restClient = mock<RestClient>();
|
||||
const i18nService = mock<I18nService>();
|
||||
|
||||
beforeEach(() => {
|
||||
const keyAvailable = of({} as UserKey);
|
||||
@ -68,7 +46,14 @@ describe("ForwarderGeneratorStrategy", () => {
|
||||
|
||||
describe("durableState", () => {
|
||||
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);
|
||||
|
||||
@ -76,7 +61,14 @@ describe("ForwarderGeneratorStrategy", () => {
|
||||
});
|
||||
|
||||
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 secondResult = strategy.durableState(SomeUser);
|
||||
@ -85,7 +77,14 @@ describe("ForwarderGeneratorStrategy", () => {
|
||||
});
|
||||
|
||||
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 secondResult = strategy.durableState(AnotherUser);
|
||||
@ -98,7 +97,14 @@ describe("ForwarderGeneratorStrategy", () => {
|
||||
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
|
||||
"should map any input (= %p) to the default policy evaluator",
|
||||
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 = 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,32 +1,39 @@
|
||||
import { map } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import {
|
||||
SingleUserState,
|
||||
StateProvider,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||
ApiSettings,
|
||||
IntegrationRequest,
|
||||
RestClient,
|
||||
} from "@bitwarden/common/tools/integration/rpc";
|
||||
import { BufferedState } from "@bitwarden/common/tools/state/buffered-state";
|
||||
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 { SecretState } from "@bitwarden/common/tools/state/secret-state";
|
||||
import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { 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 { ApiOptions, NoPolicy } from "../types";
|
||||
import { clone$PerUserId, sharedByUserId } from "../util";
|
||||
import { NoPolicy } from "../types";
|
||||
import { observe$PerUserId, sharedByUserId } from "../util";
|
||||
|
||||
import { OptionsClassifier } from "./options-classifier";
|
||||
|
||||
const OPTIONS_FRAME_SIZE = 512;
|
||||
|
||||
/** An email forwarding service configurable through an API. */
|
||||
export abstract class ForwarderGeneratorStrategy<
|
||||
Options extends ApiOptions,
|
||||
export class ForwarderGeneratorStrategy<
|
||||
Settings extends ApiSettings,
|
||||
Options extends Settings & IntegrationRequest = Settings & IntegrationRequest,
|
||||
> extends GeneratorStrategy<Options, NoPolicy> {
|
||||
/** Initializes the generator strategy
|
||||
* @param encryptService protects sensitive forwarder options
|
||||
@ -34,26 +41,45 @@ export abstract class ForwarderGeneratorStrategy<
|
||||
* @param stateProvider creates the durable state for options storage
|
||||
*/
|
||||
constructor(
|
||||
private readonly configuration: ForwarderConfiguration<Settings>,
|
||||
private client: RestClient,
|
||||
private i18nService: I18nService,
|
||||
private readonly encryptService: EncryptService,
|
||||
private readonly keyService: CryptoService,
|
||||
private stateProvider: StateProvider,
|
||||
private readonly defaultOptions: Options,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** configures forwarder secret storage */
|
||||
protected abstract readonly key: UserKeyDefinition<Options>;
|
||||
|
||||
/** configures forwarder import buffer */
|
||||
protected abstract readonly rolloverKey: BufferedKeyDefinition<Options, Options>;
|
||||
|
||||
// configuration
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
||||
defaults$ = observe$PerUserId<Options>(
|
||||
() => this.configuration.forwarder.defaultSettings as Options,
|
||||
);
|
||||
toEvaluator = newDefaultEvaluator<Options>();
|
||||
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
|
||||
private getUserSecrets(userId: UserId): SingleUserState<Options> {
|
||||
// construct the encryptor
|
||||
@ -61,23 +87,27 @@ export abstract class ForwarderGeneratorStrategy<
|
||||
const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer);
|
||||
|
||||
// always exclude request properties
|
||||
const classifier = SecretClassifier.allSecret<Options>().exclude("website");
|
||||
const classifier = new OptionsClassifier<Settings, Options>();
|
||||
|
||||
// Derive the secret key definition
|
||||
const key = SecretKeyDefinition.value(this.key.stateDefinition, this.key.key, classifier, {
|
||||
deserializer: (d) => this.key.deserializer(d),
|
||||
cleanupDelayMs: this.key.cleanupDelayMs,
|
||||
clearOn: this.key.clearOn,
|
||||
});
|
||||
const key = SecretKeyDefinition.value<Options, Record<string, never>, Settings>(
|
||||
this.key.stateDefinition,
|
||||
this.key.key,
|
||||
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">`
|
||||
const secretState = SecretState.from<
|
||||
Options,
|
||||
void,
|
||||
Options,
|
||||
Record<keyof Options, never>,
|
||||
Omit<Options, "website">
|
||||
>(userId, key, this.stateProvider, encryptor);
|
||||
const secretState = SecretState.from<Options, void, Options, Record<string, never>, Settings>(
|
||||
userId,
|
||||
key,
|
||||
this.stateProvider,
|
||||
encryptor,
|
||||
);
|
||||
|
||||
// rollover should occur once the user key is available for decryption
|
||||
const canDecrypt$ = this.keyService
|
||||
@ -90,6 +120,39 @@ export abstract class ForwarderGeneratorStrategy<
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
@ -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: "",
|
||||
});
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
@ -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: "",
|
||||
});
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
@ -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: "",
|
||||
});
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
@ -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: "",
|
||||
});
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
@ -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: "",
|
||||
});
|
@ -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;
|
||||
}
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
@ -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: "",
|
||||
});
|
@ -1,11 +1,6 @@
|
||||
export { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy";
|
||||
export { PassphraseGeneratorStrategy } from "./passphrase-generator-strategy";
|
||||
export { PasswordGeneratorStrategy } from "./password-generator-strategy";
|
||||
export { CatchallGeneratorStrategy } from "./catchall-generator-strategy";
|
||||
export { SubaddressGeneratorStrategy } from "./subaddress-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";
|
||||
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
@ -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>;
|
||||
}
|
||||
}
|
@ -6,7 +6,7 @@ import { DefaultPassphraseBoundaries, DefaultPassphraseGenerationOptions, Polici
|
||||
import { PasswordRandomizer } from "../engine";
|
||||
import { mapPolicyToEvaluator } from "../rx";
|
||||
import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types";
|
||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
||||
import { observe$PerUserId, sharedStateByUserId } from "../util";
|
||||
|
||||
import { PASSPHRASE_SETTINGS } from "./storage";
|
||||
|
||||
@ -25,7 +25,7 @@ export class PassphraseGeneratorStrategy
|
||||
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(PASSPHRASE_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(DefaultPassphraseGenerationOptions);
|
||||
defaults$ = observe$PerUserId(() => DefaultPassphraseGenerationOptions);
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
toEvaluator() {
|
||||
return mapPolicyToEvaluator(Policies.Passphrase);
|
||||
|
@ -6,7 +6,7 @@ import { Policies, DefaultPasswordGenerationOptions } from "../data";
|
||||
import { PasswordRandomizer } from "../engine";
|
||||
import { mapPolicyToEvaluator } from "../rx";
|
||||
import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types";
|
||||
import { clone$PerUserId, sharedStateByUserId, sum } from "../util";
|
||||
import { observe$PerUserId, sharedStateByUserId, sum } from "../util";
|
||||
|
||||
import { PASSWORD_SETTINGS } from "./storage";
|
||||
|
||||
@ -24,7 +24,7 @@ export class PasswordGeneratorStrategy
|
||||
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(PASSWORD_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(DefaultPasswordGenerationOptions);
|
||||
defaults$ = observe$PerUserId(() => DefaultPasswordGenerationOptions);
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
toEvaluator() {
|
||||
return mapPolicyToEvaluator(Policies.Password);
|
||||
|
@ -4,18 +4,6 @@ import {
|
||||
SUBADDRESS_SETTINGS,
|
||||
PASSPHRASE_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";
|
||||
|
||||
describe("Key definitions", () => {
|
||||
@ -58,112 +46,4 @@ describe("Key definitions", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,15 +1,10 @@
|
||||
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||
|
||||
import {
|
||||
PassphraseGenerationOptions,
|
||||
PasswordGenerationOptions,
|
||||
CatchallGenerationOptions,
|
||||
EffUsernameGenerationOptions,
|
||||
ApiOptions,
|
||||
EmailDomainOptions,
|
||||
EmailPrefixOptions,
|
||||
SelfHostedApiOptions,
|
||||
SubaddressGenerationOptions,
|
||||
} from "../types";
|
||||
|
||||
@ -62,123 +57,3 @@ export const SUBADDRESS_SETTINGS = new UserKeyDefinition<SubaddressGenerationOpt
|
||||
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"],
|
||||
},
|
||||
);
|
||||
|
@ -6,7 +6,7 @@ import { DefaultSubaddressOptions } from "../data";
|
||||
import { EmailCalculator, EmailRandomizer } from "../engine";
|
||||
import { newDefaultEvaluator } from "../rx";
|
||||
import { SubaddressGenerationOptions, NoPolicy } from "../types";
|
||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
||||
import { observe$PerUserId, sharedStateByUserId } from "../util";
|
||||
|
||||
import { SUBADDRESS_SETTINGS } from "./storage";
|
||||
|
||||
@ -30,7 +30,7 @@ export class SubaddressGeneratorStrategy
|
||||
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(SUBADDRESS_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
||||
defaults$ = observe$PerUserId(() => this.defaultOptions);
|
||||
toEvaluator = newDefaultEvaluator<SubaddressGenerationOptions>();
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { RequestOptions } from "./forwarder-options";
|
||||
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||
|
||||
import { UsernameGenerationMode } from "./generator-options";
|
||||
|
||||
/** 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`
|
||||
*/
|
||||
catchallDomain?: string;
|
||||
} & RequestOptions;
|
||||
} & IntegrationRequest;
|
||||
|
@ -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 */
|
||||
export type EffUsernameGenerationOptions = {
|
||||
@ -7,4 +7,4 @@ export type EffUsernameGenerationOptions = {
|
||||
|
||||
/** when true, a random number is appended to the username */
|
||||
wordIncludeNumber?: boolean;
|
||||
} & RequestOptions;
|
||||
} & IntegrationRequest;
|
||||
|
@ -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.
|
||||
* @remarks These are used to select forwarder-specific options.
|
||||
* The must be kept in sync with the forwarder implementations.
|
||||
*/
|
||||
export type ForwarderId =
|
||||
| "anonaddy"
|
||||
| "duckduckgo"
|
||||
| "fastmail"
|
||||
| "firefoxrelay"
|
||||
| "forwardemail"
|
||||
| "simplelogin";
|
||||
export type ForwarderId = IntegrationId;
|
||||
|
||||
/** Metadata format for email forwarding services. */
|
||||
export type ForwarderMetadata = {
|
||||
@ -23,50 +26,13 @@ export type ForwarderMetadata = {
|
||||
};
|
||||
|
||||
/** Options common to all forwarder APIs */
|
||||
export type ApiOptions = {
|
||||
/** 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;
|
||||
};
|
||||
export type ApiOptions = ApiSettings & IntegrationRequest;
|
||||
|
||||
/** Api configuration for forwarders that support self-hosted installations. */
|
||||
export type SelfHostedApiOptions = ApiOptions & {
|
||||
/** The base URL of the forwarder's API.
|
||||
* When this is empty, the forwarder's default production API is used.
|
||||
*/
|
||||
baseUrl: string;
|
||||
};
|
||||
export type SelfHostedApiOptions = SelfHostedApiSettings & IntegrationRequest;
|
||||
|
||||
/** Api configuration for forwarders that support custom domains. */
|
||||
export type EmailDomainOptions = {
|
||||
/** 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;
|
||||
};
|
||||
export type EmailDomainOptions = EmailDomainSettings;
|
||||
|
||||
/** Api configuration for forwarders that support custom email parts. */
|
||||
export type EmailPrefixOptions = EmailDomainOptions & {
|
||||
/** 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;
|
||||
};
|
||||
export type EmailPrefixOptions = EmailDomainSettings & EmailPrefixSettings;
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { RequestOptions } from "./forwarder-options";
|
||||
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||
|
||||
import { UsernameGenerationMode } from "./generator-options";
|
||||
|
||||
/** Settings supported when generating an email subaddress */
|
||||
@ -8,4 +9,4 @@ export type SubaddressGenerationOptions = {
|
||||
|
||||
/** the email address the subaddress is applied to. */
|
||||
subaddressEmail?: string;
|
||||
} & RequestOptions;
|
||||
} & IntegrationRequest;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
SingleUserState,
|
||||
@ -8,14 +8,17 @@ import {
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
/** 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>>();
|
||||
|
||||
return (key: UserId) => {
|
||||
let value = _subjects.get(key);
|
||||
|
||||
if (value === undefined) {
|
||||
value = new BehaviorSubject({ ...defaultValue });
|
||||
const initialValue = create();
|
||||
value = new BehaviorSubject({ ...initialValue } as Value);
|
||||
_subjects.set(key, value);
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,8 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { engine, services, strategies } from "@bitwarden/generator-core";
|
||||
import { RestClient } from "@bitwarden/common/tools/integration/rpc";
|
||||
import { engine, services, strategies, Integrations } from "@bitwarden/generator-core";
|
||||
import { DefaultGeneratorNavigationService } from "@bitwarden/generator-navigation";
|
||||
|
||||
import { LegacyUsernameGenerationService } from "./legacy-username-generation.service";
|
||||
@ -17,12 +18,7 @@ const {
|
||||
CatchallGeneratorStrategy,
|
||||
SubaddressGeneratorStrategy,
|
||||
EffUsernameGeneratorStrategy,
|
||||
AddyIoForwarder,
|
||||
DuckDuckGoForwarder,
|
||||
FastmailForwarder,
|
||||
FirefoxRelayForwarder,
|
||||
ForwardEmailForwarder,
|
||||
SimpleLoginForwarder,
|
||||
ForwarderGeneratorStrategy,
|
||||
} = strategies;
|
||||
|
||||
export function legacyUsernameGenerationServiceFactory(
|
||||
@ -35,6 +31,7 @@ export function legacyUsernameGenerationServiceFactory(
|
||||
stateProvider: StateProvider,
|
||||
): UsernameGenerationServiceAbstraction {
|
||||
const randomizer = new CryptoServiceRandomizer(cryptoService);
|
||||
const restClient = new RestClient(apiService, i18nService);
|
||||
const usernameRandomizer = new UsernameRandomizer(randomizer);
|
||||
const emailRandomizer = new EmailRandomizer(randomizer);
|
||||
const emailCalculator = new EmailCalculator();
|
||||
@ -55,23 +52,45 @@ export function legacyUsernameGenerationServiceFactory(
|
||||
);
|
||||
|
||||
const addyIo = new DefaultGeneratorService(
|
||||
new AddyIoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
|
||||
new ForwarderGeneratorStrategy(
|
||||
Integrations.AddyIo,
|
||||
restClient,
|
||||
i18nService,
|
||||
encryptService,
|
||||
cryptoService,
|
||||
stateProvider,
|
||||
),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const duckDuckGo = new DefaultGeneratorService(
|
||||
new DuckDuckGoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
|
||||
new ForwarderGeneratorStrategy(
|
||||
Integrations.DuckDuckGo,
|
||||
restClient,
|
||||
i18nService,
|
||||
encryptService,
|
||||
cryptoService,
|
||||
stateProvider,
|
||||
),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const fastmail = new DefaultGeneratorService(
|
||||
new FastmailForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
|
||||
new ForwarderGeneratorStrategy(
|
||||
Integrations.Fastmail,
|
||||
restClient,
|
||||
i18nService,
|
||||
encryptService,
|
||||
cryptoService,
|
||||
stateProvider,
|
||||
),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const firefoxRelay = new DefaultGeneratorService(
|
||||
new FirefoxRelayForwarder(
|
||||
apiService,
|
||||
new ForwarderGeneratorStrategy(
|
||||
Integrations.FirefoxRelay,
|
||||
restClient,
|
||||
i18nService,
|
||||
encryptService,
|
||||
cryptoService,
|
||||
@ -81,8 +100,9 @@ export function legacyUsernameGenerationServiceFactory(
|
||||
);
|
||||
|
||||
const forwardEmail = new DefaultGeneratorService(
|
||||
new ForwardEmailForwarder(
|
||||
apiService,
|
||||
new ForwarderGeneratorStrategy(
|
||||
Integrations.ForwardEmail,
|
||||
restClient,
|
||||
i18nService,
|
||||
encryptService,
|
||||
cryptoService,
|
||||
@ -92,7 +112,14 @@ export function legacyUsernameGenerationServiceFactory(
|
||||
);
|
||||
|
||||
const simpleLogin = new DefaultGeneratorService(
|
||||
new SimpleLoginForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
|
||||
new ForwarderGeneratorStrategy(
|
||||
Integrations.SimpleLogin,
|
||||
restClient,
|
||||
i18nService,
|
||||
encryptService,
|
||||
cryptoService,
|
||||
stateProvider,
|
||||
),
|
||||
policyService,
|
||||
);
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { IntegrationId } from "@bitwarden/common/tools/integration";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
GeneratorService,
|
||||
@ -185,7 +186,7 @@ describe("LegacyPasswordGenerationService", () => {
|
||||
const navigation = createNavigationGenerator({
|
||||
type: "passphrase",
|
||||
username: "word",
|
||||
forwarder: "simplelogin",
|
||||
forwarder: "simplelogin" as IntegrationId,
|
||||
});
|
||||
const accountService = mockAccountServiceWith(SomeUser);
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
@ -496,7 +497,7 @@ describe("LegacyPasswordGenerationService", () => {
|
||||
const navigation = createNavigationGenerator({
|
||||
type: "password",
|
||||
username: "forwarded",
|
||||
forwarder: "firefoxrelay",
|
||||
forwarder: "firefoxrelay" as IntegrationId,
|
||||
});
|
||||
const accountService = mockAccountServiceWith(SomeUser);
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
|
@ -23,6 +23,7 @@ import {
|
||||
DefaultSubaddressOptions,
|
||||
SubaddressGenerationOptions,
|
||||
policies,
|
||||
Integrations,
|
||||
} from "@bitwarden/generator-core";
|
||||
import {
|
||||
GeneratorNavigationPolicy,
|
||||
@ -724,7 +725,7 @@ describe("LegacyUsernameGenerationService", () => {
|
||||
});
|
||||
|
||||
options.type = "forwarded";
|
||||
options.forwardedService = "anonaddy";
|
||||
options.forwardedService = Integrations.AddyIo.id;
|
||||
await generator.saveOptions(options);
|
||||
|
||||
expect(addyIo.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
@ -735,7 +736,7 @@ describe("LegacyUsernameGenerationService", () => {
|
||||
});
|
||||
|
||||
options.type = "forwarded";
|
||||
options.forwardedService = "duckduckgo";
|
||||
options.forwardedService = Integrations.DuckDuckGo.id;
|
||||
await generator.saveOptions(options);
|
||||
|
||||
expect(duckDuckGo.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
@ -744,7 +745,7 @@ describe("LegacyUsernameGenerationService", () => {
|
||||
});
|
||||
|
||||
options.type = "forwarded";
|
||||
options.forwardedService = "fastmail";
|
||||
options.forwardedService = Integrations.Fastmail.id;
|
||||
await generator.saveOptions(options);
|
||||
|
||||
expect(fastmail.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
@ -753,7 +754,7 @@ describe("LegacyUsernameGenerationService", () => {
|
||||
});
|
||||
|
||||
options.type = "forwarded";
|
||||
options.forwardedService = "firefoxrelay";
|
||||
options.forwardedService = Integrations.FirefoxRelay.id;
|
||||
await generator.saveOptions(options);
|
||||
|
||||
expect(firefoxRelay.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
@ -762,7 +763,7 @@ describe("LegacyUsernameGenerationService", () => {
|
||||
});
|
||||
|
||||
options.type = "forwarded";
|
||||
options.forwardedService = "forwardemail";
|
||||
options.forwardedService = Integrations.ForwardEmail.id;
|
||||
await generator.saveOptions(options);
|
||||
|
||||
expect(forwardEmail.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
@ -772,7 +773,7 @@ describe("LegacyUsernameGenerationService", () => {
|
||||
});
|
||||
|
||||
options.type = "forwarded";
|
||||
options.forwardedService = "simplelogin";
|
||||
options.forwardedService = Integrations.SimpleLogin.id;
|
||||
await generator.saveOptions(options);
|
||||
|
||||
expect(simpleLogin.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { zip, firstValueFrom, map, concatMap, combineLatest } from "rxjs";
|
||||
|
||||
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 {
|
||||
ApiOptions,
|
||||
EmailDomainOptions,
|
||||
EmailPrefixOptions,
|
||||
RequestOptions,
|
||||
SelfHostedApiOptions,
|
||||
NoPolicy,
|
||||
GeneratorService,
|
||||
@ -30,12 +30,12 @@ type MappedOptions = {
|
||||
subaddress: SubaddressGenerationOptions;
|
||||
};
|
||||
forwarders: {
|
||||
addyIo: SelfHostedApiOptions & EmailDomainOptions & RequestOptions;
|
||||
duckDuckGo: ApiOptions & RequestOptions;
|
||||
fastmail: ApiOptions & EmailPrefixOptions & RequestOptions;
|
||||
firefoxRelay: ApiOptions & RequestOptions;
|
||||
forwardEmail: ApiOptions & EmailDomainOptions & RequestOptions;
|
||||
simpleLogin: SelfHostedApiOptions & RequestOptions;
|
||||
addyIo: SelfHostedApiOptions & EmailDomainOptions & IntegrationRequest;
|
||||
duckDuckGo: ApiOptions & IntegrationRequest;
|
||||
fastmail: ApiOptions & EmailPrefixOptions & IntegrationRequest;
|
||||
firefoxRelay: ApiOptions & IntegrationRequest;
|
||||
forwardEmail: ApiOptions & EmailDomainOptions & IntegrationRequest;
|
||||
simpleLogin: SelfHostedApiOptions & IntegrationRequest;
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||
import {
|
||||
ForwarderId,
|
||||
RequestOptions,
|
||||
CatchallGenerationOptions,
|
||||
EffUsernameGenerationOptions,
|
||||
SubaddressGenerationOptions,
|
||||
@ -10,7 +10,7 @@ import {
|
||||
export type UsernameGeneratorOptions = EffUsernameGenerationOptions &
|
||||
SubaddressGenerationOptions &
|
||||
CatchallGenerationOptions &
|
||||
RequestOptions & {
|
||||
IntegrationRequest & {
|
||||
type?: UsernameGeneratorType;
|
||||
forwardedService?: ForwarderId | "";
|
||||
forwardedAnonAddyApiToken?: string;
|
||||
|
Loading…
Reference in New Issue
Block a user