mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-25 16:59:17 +01:00
[PM-9598] Introduce integrations (#10019)
Factor general integration logic out of the forwarder code. - Integration metadata - information generalized across any integration - Rpc mechanism - first step towards applying policy to integrations is abstracting their service calls (e.g. static baseUrl) Email forwarder integrations embedded this metadata. It was extracted to begin the process of making integrations compatible with meta-systems like policy. This PR consists mostly of interfaces, which are not particularly useful on their own. Examples on how they're used can be found in the readme.
This commit is contained in:
parent
7e2b4d9652
commit
24b84985f5
1
.github/whitelist-capital-letters.txt
vendored
1
.github/whitelist-capital-letters.txt
vendored
@ -18,6 +18,7 @@
|
||||
./libs/admin-console/README.md
|
||||
./libs/auth/README.md
|
||||
./libs/billing/README.md
|
||||
./libs/common/src/tools/integration/README.md
|
||||
./libs/platform/README.md
|
||||
./libs/tools/README.md
|
||||
./libs/tools/export/vault-export/README.md
|
||||
|
86
libs/common/src/tools/integration/README.md
Normal file
86
libs/common/src/tools/integration/README.md
Normal file
@ -0,0 +1,86 @@
|
||||
This module defines interfaces and helpers for creating vendor integration sites.
|
||||
|
||||
## RPC
|
||||
|
||||
> ⚠️ **Only use for extension points!**
|
||||
> This logic is not suitable for general use. Making calls to the Bitwarden server api
|
||||
> using `@bitwarden/common/tools/integration/rpc` is prohibited.
|
||||
|
||||
Interfaces and helpers defining a remote procedure call to a vendor's service. These
|
||||
types provide extension points to produce and process the call without exposing a
|
||||
generalized fetch API.
|
||||
|
||||
## Sample usage
|
||||
|
||||
An email forwarder configuration:
|
||||
|
||||
```typescript
|
||||
// define RPC shapes;
|
||||
// * the request format, `RequestOptions` is common to all calls
|
||||
// * the context operates on forwarder-specific settings provided by `state`.
|
||||
type CreateForwardingEmailConfig<Settings> = RpcConfiguration<
|
||||
RequestOptions,
|
||||
ForwarderContext<Settings>
|
||||
>;
|
||||
|
||||
// how a forwarder integration point might represent its configuration
|
||||
type ForwarderConfiguration<Settings> = IntegrationConfiguration & {
|
||||
forwarder: {
|
||||
defaultState: Settings;
|
||||
createForwardingEmail: CreateForwardingEmailConfig<Settings>;
|
||||
};
|
||||
};
|
||||
|
||||
// how an importer integration point might represent its configuration
|
||||
type ImporterConfiguration = IntegrationConfiguration & {
|
||||
importer: {
|
||||
fileless: false | { selector: string };
|
||||
formats: ContentType[];
|
||||
crep:
|
||||
| false
|
||||
| {
|
||||
/* credential exchange protocol configuration */
|
||||
};
|
||||
// ...
|
||||
};
|
||||
};
|
||||
|
||||
// how a plugin might be structured
|
||||
export type JustTrustUsSettings = ApiSettings & EmailDomainSettings;
|
||||
export type JustTrustUsConfiguration = ForwarderConfiguration<JustTrustUsSettings> &
|
||||
ImporterConfiguration;
|
||||
|
||||
export const JustTrustUs = {
|
||||
// common metadata
|
||||
id: "justrustus",
|
||||
name: "Just Trust Us, LLC",
|
||||
extends: ["forwarder"],
|
||||
|
||||
// API conventions
|
||||
selfHost: "never",
|
||||
baseUrl: "https://api.just-trust.us/v1",
|
||||
authenticate(settings: ApiSettings, context: IntegrationContext) {
|
||||
return { Authorization: "Bearer " + context.authenticationToken(settings) };
|
||||
},
|
||||
|
||||
// forwarder specific config
|
||||
forwarder: {
|
||||
defaultState: { domain: "just-trust.us" },
|
||||
|
||||
// specific RPC call
|
||||
createForwardingEmail: {
|
||||
url: () => context.baseUrl() + "/fowarder",
|
||||
body: (request: RequestOptions) => ({ description: context.generatedBy(request) }),
|
||||
hasJsonPayload: (response) => response.status === 200,
|
||||
processJson: (json) => json.email,
|
||||
},
|
||||
},
|
||||
|
||||
// importer specific config
|
||||
importer: {
|
||||
fileless: false,
|
||||
crep: false,
|
||||
formats: ["text/csv", "application/json"],
|
||||
},
|
||||
} as JustTrustUsConfiguration;
|
||||
```
|
4
libs/common/src/tools/integration/extension-point-id.ts
Normal file
4
libs/common/src/tools/integration/extension-point-id.ts
Normal file
@ -0,0 +1,4 @@
|
||||
/** well-known name for a feature extensible through an integration. */
|
||||
// The forwarder extension point is presently hard-coded in `@bitwarden/generator-legacy/`.
|
||||
// v2 will load forwarders using an extension provider.
|
||||
export type ExtensionPointId = "forwarder";
|
5
libs/common/src/tools/integration/index.ts
Normal file
5
libs/common/src/tools/integration/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./extension-point-id";
|
||||
export * from "./integration-configuration";
|
||||
export * from "./integration-context";
|
||||
export * from "./integration-id";
|
||||
export * from "./integration-metadata";
|
@ -0,0 +1,9 @@
|
||||
import { IntegrationContext } from "./integration-context";
|
||||
import { IntegrationMetadata } from "./integration-metadata";
|
||||
import { ApiSettings, 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;
|
||||
};
|
195
libs/common/src/tools/integration/integration-context.spec.ts
Normal file
195
libs/common/src/tools/integration/integration-context.spec.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { IntegrationContext } from "./integration-context";
|
||||
import { IntegrationId } from "./integration-id";
|
||||
import { IntegrationMetadata } from "./integration-metadata";
|
||||
|
||||
const EXAMPLE_META = Object.freeze({
|
||||
// arbitrary
|
||||
id: "simplelogin" as IntegrationId,
|
||||
name: "Example",
|
||||
// arbitrary
|
||||
extends: ["forwarder"],
|
||||
baseUrl: "https://api.example.com",
|
||||
selfHost: "maybe",
|
||||
} as IntegrationMetadata);
|
||||
|
||||
describe("IntegrationContext", () => {
|
||||
const i18n = mock<I18nService>();
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("baseUrl", () => {
|
||||
it("outputs the base url from metadata", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
||||
|
||||
const result = context.baseUrl();
|
||||
|
||||
expect(result).toBe("https://api.example.com");
|
||||
});
|
||||
|
||||
it("throws when the baseurl isn't defined in metadata", () => {
|
||||
const noBaseUrl: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
selfHost: "maybe",
|
||||
};
|
||||
i18n.t.mockReturnValue("error");
|
||||
|
||||
const context = new IntegrationContext(noBaseUrl, i18n);
|
||||
|
||||
expect(() => context.baseUrl()).toThrow("error");
|
||||
});
|
||||
|
||||
it("reads from the settings", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
||||
|
||||
const result = context.baseUrl({ baseUrl: "httpbin.org" });
|
||||
|
||||
expect(result).toBe("httpbin.org");
|
||||
});
|
||||
|
||||
it("ignores settings when selfhost is 'never'", () => {
|
||||
const selfHostNever: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
baseUrl: "example.com",
|
||||
selfHost: "never",
|
||||
};
|
||||
const context = new IntegrationContext(selfHostNever, i18n);
|
||||
|
||||
const result = context.baseUrl({ baseUrl: "httpbin.org" });
|
||||
|
||||
expect(result).toBe("example.com");
|
||||
});
|
||||
|
||||
it("always reads the settings when 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 success
|
||||
const result = context.baseUrl({ baseUrl: "http.bin" });
|
||||
expect(result).toBe("http.bin");
|
||||
|
||||
// expect error
|
||||
i18n.t.mockReturnValue("error");
|
||||
expect(() => context.baseUrl()).toThrow("error");
|
||||
});
|
||||
|
||||
it("reads from the metadata by default when selfhost is 'maybe'", () => {
|
||||
const selfHostMaybe: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
baseUrl: "example.com",
|
||||
selfHost: "maybe",
|
||||
};
|
||||
|
||||
const context = new IntegrationContext(selfHostMaybe, i18n);
|
||||
|
||||
const result = context.baseUrl();
|
||||
|
||||
expect(result).toBe("example.com");
|
||||
});
|
||||
|
||||
it("overrides the metadata when selfhost is 'maybe'", () => {
|
||||
const selfHostMaybe: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
baseUrl: "example.com",
|
||||
selfHost: "maybe",
|
||||
};
|
||||
|
||||
const context = new IntegrationContext(selfHostMaybe, i18n);
|
||||
|
||||
const result = context.baseUrl({ baseUrl: "httpbin.org" });
|
||||
|
||||
expect(result).toBe("httpbin.org");
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticationToken", () => {
|
||||
it("reads from the settings", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
||||
|
||||
const result = context.authenticationToken({ token: "example" });
|
||||
|
||||
expect(result).toBe("example");
|
||||
});
|
||||
|
||||
it("base64 encodes the read value", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
||||
|
||||
const result = context.authenticationToken({ token: "example" }, { base64: true });
|
||||
|
||||
expect(result).toBe("ZXhhbXBsZQ==");
|
||||
});
|
||||
|
||||
it("throws an error when the value is missing", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
||||
i18n.t.mockReturnValue("error");
|
||||
|
||||
expect(() => context.authenticationToken({})).toThrow("error");
|
||||
});
|
||||
|
||||
it("throws an error when the value is empty", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
||||
i18n.t.mockReturnValue("error");
|
||||
|
||||
expect(() => context.authenticationToken({ token: "" })).toThrow("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("website", () => {
|
||||
it("returns the website", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
||||
|
||||
const result = context.website({ website: "www.example.com" });
|
||||
|
||||
expect(result).toBe("www.example.com");
|
||||
});
|
||||
|
||||
it("returns an empty string when the website is not specified", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
||||
|
||||
const result = context.website({ website: undefined });
|
||||
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatedBy", () => {
|
||||
it("creates generated by text", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
||||
i18n.t.mockReturnValue("result");
|
||||
|
||||
const result = context.generatedBy({ website: null });
|
||||
|
||||
expect(result).toBe("result");
|
||||
expect(i18n.t).toHaveBeenCalledWith("forwarderGeneratedBy", "");
|
||||
});
|
||||
|
||||
it("creates generated by text including the website", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
||||
i18n.t.mockReturnValue("result");
|
||||
|
||||
const result = context.generatedBy({ website: "www.example.com" });
|
||||
|
||||
expect(result).toBe("result");
|
||||
expect(i18n.t).toHaveBeenCalledWith("forwarderGeneratedByWithWebsite", "www.example.com");
|
||||
});
|
||||
});
|
||||
});
|
91
libs/common/src/tools/integration/integration-context.ts
Normal file
91
libs/common/src/tools/integration/integration-context.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { IntegrationMetadata } from "./integration-metadata";
|
||||
import { ApiSettings, SelfHostedApiSettings, IntegrationRequest } from "./rpc";
|
||||
|
||||
/** Utilities for processing integration settings */
|
||||
export class IntegrationContext {
|
||||
/** Instantiates an integration context
|
||||
* @param metadata - defines integration capabilities
|
||||
* @param i18n - localizes error messages
|
||||
*/
|
||||
constructor(
|
||||
readonly metadata: IntegrationMetadata,
|
||||
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) {
|
||||
// normalize baseUrl
|
||||
const setting = settings && "baseUrl" in settings ? settings.baseUrl : "";
|
||||
let result = "";
|
||||
|
||||
// look up definition
|
||||
if (this.metadata.selfHost === "always") {
|
||||
result = setting;
|
||||
} else if (this.metadata.selfHost === "never" || setting.length <= 0) {
|
||||
result = this.metadata.baseUrl ?? "";
|
||||
} else {
|
||||
result = setting;
|
||||
}
|
||||
|
||||
// postconditions
|
||||
if (result === "") {
|
||||
const error = this.i18n.t("forwarderNoUrl", this.metadata.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 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`.
|
||||
* @returns the user's authentication token
|
||||
* @throws a localized error message when the token is invalid.
|
||||
*/
|
||||
authenticationToken(settings: ApiSettings, options: { base64?: boolean } = null) {
|
||||
if (!settings.token || settings.token === "") {
|
||||
const error = this.i18n.t("forwaderInvalidToken", this.metadata.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let token = settings.token;
|
||||
if (options?.base64) {
|
||||
token = Utils.fromUtf8ToB64(token);
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/** look up the website the integration is working with.
|
||||
* @param request supplies information about the state of the extension site
|
||||
* @returns The website or an empty string if a website isn't available
|
||||
* @remarks `website` is usually supplied when generating a credential from the vault
|
||||
*/
|
||||
website(request: IntegrationRequest) {
|
||||
return request.website ?? "";
|
||||
}
|
||||
|
||||
/** look up localized text indicating Bitwarden requested the forwarding address.
|
||||
* @param request supplies information about the state of the extension site
|
||||
* @returns localized text describing a generated forwarding address
|
||||
*/
|
||||
generatedBy(request: IntegrationRequest) {
|
||||
const website = this.website(request);
|
||||
|
||||
const descriptionId =
|
||||
website === "" ? "forwarderGeneratedBy" : "forwarderGeneratedByWithWebsite";
|
||||
const description = this.i18n.t(descriptionId, website);
|
||||
|
||||
return description;
|
||||
}
|
||||
}
|
7
libs/common/src/tools/integration/integration-id.ts
Normal file
7
libs/common/src/tools/integration/integration-id.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
/** Identifies a vendor integrated into bitwarden */
|
||||
export type IntegrationId = Opaque<
|
||||
"anonaddy" | "duckduckgo" | "fastmail" | "firefoxrelay" | "forwardemail" | "simplelogin",
|
||||
"IntegrationId"
|
||||
>;
|
23
libs/common/src/tools/integration/integration-metadata.ts
Normal file
23
libs/common/src/tools/integration/integration-metadata.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { ExtensionPointId } from "./extension-point-id";
|
||||
import { IntegrationId } from "./integration-id";
|
||||
|
||||
/** The capabilities and descriptive content for an integration */
|
||||
export type IntegrationMetadata = {
|
||||
/** Uniquely identifies the integrator. */
|
||||
id: IntegrationId;
|
||||
|
||||
/** Brand name of the integrator. */
|
||||
name: string;
|
||||
|
||||
/** Features extended by the integration. */
|
||||
extends: Array<ExtensionPointId>;
|
||||
|
||||
/** Common URL for the service; this should only be undefined when selfHost is "always" */
|
||||
baseUrl?: string;
|
||||
|
||||
/** Determines whether the integration supports self-hosting;
|
||||
* "maybe" allows a service's base URLs to vary from the metadata URL
|
||||
* "never" always sets a service's baseURL from the metadata URL
|
||||
*/
|
||||
selfHost: "always" | "maybe" | "never";
|
||||
};
|
15
libs/common/src/tools/integration/rpc/api-settings.ts
Normal file
15
libs/common/src/tools/integration/rpc/api-settings.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/** Options common to all forwarder APIs */
|
||||
export type ApiSettings = {
|
||||
/** bearer token that authenticates bitwarden to the forwarder.
|
||||
* This is required to issue an API request.
|
||||
*/
|
||||
token?: string;
|
||||
};
|
||||
|
||||
/** Api configuration for forwarders that support self-hosted installations. */
|
||||
export type SelfHostedApiSettings = ApiSettings & {
|
||||
/** The base URL of the forwarder's API.
|
||||
* When this is empty, the forwarder's default production API is used.
|
||||
*/
|
||||
baseUrl: string;
|
||||
};
|
6
libs/common/src/tools/integration/rpc/index.ts
Normal file
6
libs/common/src/tools/integration/rpc/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from "./api-settings";
|
||||
export * from "./integration-request";
|
||||
export * from "./rest-client";
|
||||
export * from "./rpc-definition";
|
||||
export * from "./rpc";
|
||||
export * from "./token-header";
|
11
libs/common/src/tools/integration/rpc/integration-request.ts
Normal file
11
libs/common/src/tools/integration/rpc/integration-request.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/** Options that provide contextual information about the application state
|
||||
* when an integration is invoked.
|
||||
*/
|
||||
export type IntegrationRequest = {
|
||||
/** @param website The domain of the website the requested integration is used
|
||||
* within. This should be set to `null` when the request is not specific
|
||||
* to any website.
|
||||
* @remarks this field contains sensitive data
|
||||
*/
|
||||
website: string | null;
|
||||
};
|
164
libs/common/src/tools/integration/rpc/rest-client.spec.ts
Normal file
164
libs/common/src/tools/integration/rpc/rest-client.spec.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { IntegrationRequest } from "./integration-request";
|
||||
import { RestClient } from "./rest-client";
|
||||
import { JsonRpc } from "./rpc";
|
||||
|
||||
describe("RestClient", () => {
|
||||
const expectedRpc = {
|
||||
fetchRequest: {} as any,
|
||||
json: {},
|
||||
} as const;
|
||||
|
||||
const i18n = mock<I18nService>();
|
||||
const nativeFetchResponse = mock<Response>({ status: 200 });
|
||||
const api = mock<ApiService>();
|
||||
const rpc = mock<JsonRpc<IntegrationRequest, object>>({ requestor: { name: "mock" } });
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.t.mockImplementation((a) => a);
|
||||
|
||||
api.nativeFetch.mockResolvedValue(nativeFetchResponse);
|
||||
|
||||
rpc.toRequest.mockReturnValue(expectedRpc.fetchRequest);
|
||||
rpc.hasJsonPayload.mockReturnValue(true);
|
||||
rpc.processJson.mockImplementation((json: any) => [expectedRpc.json]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("fetchJson", () => {
|
||||
it("issues a request", async () => {
|
||||
const client = new RestClient(api, i18n);
|
||||
const request: IntegrationRequest = { website: null };
|
||||
|
||||
const result = await client.fetchJson(rpc, request);
|
||||
|
||||
expect(result).toBe(expectedRpc.json);
|
||||
});
|
||||
|
||||
it("invokes the constructed request", async () => {
|
||||
const client = new RestClient(api, i18n);
|
||||
const request: IntegrationRequest = { website: null };
|
||||
|
||||
await client.fetchJson(rpc, request);
|
||||
|
||||
expect(api.nativeFetch).toHaveBeenCalledWith(expectedRpc.fetchRequest);
|
||||
});
|
||||
|
||||
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 });
|
||||
api.nativeFetch.mockResolvedValue(response);
|
||||
|
||||
const result = client.fetchJson(rpc, request);
|
||||
|
||||
await expect(result).rejects.toEqual("forwarderInvalidToken");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[401, "message"],
|
||||
[403, "message"],
|
||||
[401, "error"],
|
||||
[403, "error"],
|
||||
])(
|
||||
"throws an invalid token detailed error when HTTP status is %i and the payload has a %s",
|
||||
async (status, property) => {
|
||||
const client = new RestClient(api, i18n);
|
||||
const request: IntegrationRequest = { website: null };
|
||||
const response = mock<Response>({
|
||||
status,
|
||||
text: () => Promise.resolve(`{ "${property}": "expected message" }`),
|
||||
});
|
||||
api.nativeFetch.mockResolvedValue(response);
|
||||
|
||||
const result = client.fetchJson(rpc, request);
|
||||
|
||||
await expect(result).rejects.toEqual("forwarderInvalidTokenWithMessage");
|
||||
expect(i18n.t).toHaveBeenCalledWith(
|
||||
"forwarderInvalidTokenWithMessage",
|
||||
"mock",
|
||||
"expected message",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([[500], [501]])(
|
||||
"throws a forwarder error with the status text when HTTP status is %i",
|
||||
async (status) => {
|
||||
const client = new RestClient(api, i18n);
|
||||
const request: IntegrationRequest = { website: null };
|
||||
const response = mock<Response>({ status, statusText: "expectedResult" });
|
||||
api.nativeFetch.mockResolvedValue(response);
|
||||
|
||||
const result = client.fetchJson(rpc, request);
|
||||
|
||||
await expect(result).rejects.toEqual("forwarderError");
|
||||
expect(i18n.t).toHaveBeenCalledWith("forwarderError", "mock", "expectedResult");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[500, "message"],
|
||||
[500, "message"],
|
||||
[501, "error"],
|
||||
[501, "error"],
|
||||
])(
|
||||
"throws a detailed forwarder error when HTTP status is %i and the payload has a %s",
|
||||
async (status, property) => {
|
||||
const client = new RestClient(api, i18n);
|
||||
const request: IntegrationRequest = { website: null };
|
||||
const response = mock<Response>({
|
||||
status,
|
||||
text: () => Promise.resolve(`{ "${property}": "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("outputs an error if there's no json payload", async () => {
|
||||
const client = new RestClient(api, i18n);
|
||||
rpc.hasJsonPayload.mockReturnValue(false);
|
||||
const request: IntegrationRequest = { website: null };
|
||||
|
||||
const result = client.fetchJson(rpc, request);
|
||||
|
||||
await expect(result).rejects.toEqual("forwarderUnknownError");
|
||||
});
|
||||
|
||||
it("processes an ok JSON payload", async () => {
|
||||
const client = new RestClient(api, i18n);
|
||||
rpc.processJson.mockReturnValue([{ foo: true }]);
|
||||
const request: IntegrationRequest = { website: null };
|
||||
|
||||
const result = client.fetchJson(rpc, request);
|
||||
|
||||
await expect(result).resolves.toEqual({ foo: true });
|
||||
});
|
||||
|
||||
it("processes an erroneous JSON payload", async () => {
|
||||
const client = new RestClient(api, i18n);
|
||||
rpc.processJson.mockReturnValue([undefined, "expected message"]);
|
||||
const request: IntegrationRequest = { website: null };
|
||||
|
||||
const result = client.fetchJson(rpc, request);
|
||||
|
||||
await expect(result).rejects.toEqual("forwarderError");
|
||||
expect(i18n.t).toHaveBeenCalledWith("forwarderError", "mock", "expected message");
|
||||
});
|
||||
});
|
||||
});
|
68
libs/common/src/tools/integration/rpc/rest-client.ts
Normal file
68
libs/common/src/tools/integration/rpc/rest-client.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { IntegrationRequest } from "./integration-request";
|
||||
import { JsonRpc } from "./rpc";
|
||||
|
||||
/** Makes remote procedure calls using a RESTful interface. */
|
||||
export class RestClient {
|
||||
constructor(
|
||||
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>,
|
||||
params: Parameters,
|
||||
): Promise<Response> {
|
||||
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;
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
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("{")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const json = JSON.parse(body);
|
||||
if ("error" in json) {
|
||||
return json.error;
|
||||
} else if ("message" in json) {
|
||||
return json.message;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
40
libs/common/src/tools/integration/rpc/rpc-definition.ts
Normal file
40
libs/common/src/tools/integration/rpc/rpc-definition.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { IntegrationRequest } from "./integration-request";
|
||||
|
||||
/** Defines how an integration processes an RPC call.
|
||||
* @remarks This interface should not be used directly. Your integration should specialize
|
||||
* it to fill a specific use-case. For example, the forwarder provides two specializations as follows:
|
||||
*
|
||||
* // optional; supplements the `IntegrationRequest` with an integrator-supplied account Id
|
||||
* type GetAccountId = RpcConfiguration<IntegrationRequest, ForwarderContext<Settings>, ForwarderRequest>
|
||||
*
|
||||
* // generates a forwarding address
|
||||
* type CreateForwardingEmail = RpcConfiguration<ForwarderRequest, ForwarderContext<Settings>, string>
|
||||
*/
|
||||
export interface RpcConfiguration<Request extends IntegrationRequest, Helper, Result> {
|
||||
/** determine the URL of the lookup
|
||||
* @param request describes the state of the integration site
|
||||
* @param helper supplies logic from bitwarden specific to the integration site
|
||||
*/
|
||||
url(request: Request, helper: Helper): string;
|
||||
|
||||
/** format the body of the rpc call; when this method is not supplied, the request omits the body
|
||||
* @param request describes the state of the integration site
|
||||
* @param helper supplies logic from bitwarden specific to the integration site
|
||||
* @returns a JSON object supplied as the body of the request
|
||||
*/
|
||||
body?(request: Request, helper: Helper): any;
|
||||
|
||||
/** returns true when there's a JSON payload to process
|
||||
* @param response the fetch API response returned by the RPC call
|
||||
* @param helper supplies logic from bitwarden specific to the integration site
|
||||
*/
|
||||
hasJsonPayload(response: Response, helper: Helper): boolean;
|
||||
|
||||
/** map body parsed as json payload of the rpc call.
|
||||
* @param json the object to map
|
||||
* @param helper supplies logic from bitwarden specific to the integration site
|
||||
* @returns When the JSON is processed successfully, a 1-tuple whose value is the processed result.
|
||||
* Otherwise, a 2-tuple whose first value is undefined, and whose second value is an error message.
|
||||
*/
|
||||
processJson(json: any, helper: Helper): [Result?, string?];
|
||||
}
|
26
libs/common/src/tools/integration/rpc/rpc.ts
Normal file
26
libs/common/src/tools/integration/rpc/rpc.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { IntegrationMetadata } from "../integration-metadata";
|
||||
|
||||
import { IntegrationRequest } from "./integration-request";
|
||||
|
||||
/** A runtime RPC request that returns a JSON-encoded payload.
|
||||
*/
|
||||
export interface JsonRpc<Parameters extends IntegrationRequest, Result> {
|
||||
/** information about the integration requesting RPC */
|
||||
requestor: Readonly<IntegrationMetadata>;
|
||||
|
||||
/** creates a fetch request for the RPC
|
||||
* @param request describes the state of the integration site
|
||||
*/
|
||||
toRequest(request: Parameters): Request;
|
||||
|
||||
/** returns true when there should be a JSON payload to process
|
||||
* @param response the fetch API response returned by the RPC call
|
||||
*/
|
||||
hasJsonPayload(response: Response): boolean;
|
||||
|
||||
/** processes the json payload
|
||||
* @param json the object to map
|
||||
* @returns on success returns [Result], on failure returns [undefined, string]
|
||||
*/
|
||||
processJson(json: any): [Result?, string?];
|
||||
}
|
2
libs/common/src/tools/integration/rpc/token-header.ts
Normal file
2
libs/common/src/tools/integration/rpc/token-header.ts
Normal file
@ -0,0 +1,2 @@
|
||||
/** Token header patterns created by extensions */
|
||||
export type TokenHeader = { Authorization: string } | { Authentication: string };
|
Loading…
Reference in New Issue
Block a user