summaryrefslogtreecommitdiffstats
path: root/services/common/tokenserverclient.js
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /services/common/tokenserverclient.js
parentInitial commit. (diff)
downloadfirefox-upstream.tar.xz
firefox-upstream.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'services/common/tokenserverclient.js')
-rw-r--r--services/common/tokenserverclient.js475
1 files changed, 475 insertions, 0 deletions
diff --git a/services/common/tokenserverclient.js b/services/common/tokenserverclient.js
new file mode 100644
index 0000000000..0e2685b629
--- /dev/null
+++ b/services/common/tokenserverclient.js
@@ -0,0 +1,475 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = [
+ "TokenServerClient",
+ "TokenServerClientError",
+ "TokenServerClientNetworkError",
+ "TokenServerClientServerError",
+];
+
+const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm");
+const { RESTRequest } = ChromeUtils.import(
+ "resource://services-common/rest.js"
+);
+const { Observers } = ChromeUtils.import(
+ "resource://services-common/observers.js"
+);
+
+const PREF_LOG_LEVEL = "services.common.log.logger.tokenserverclient";
+
+/**
+ * Represents a TokenServerClient error that occurred on the client.
+ *
+ * This is the base type for all errors raised by client operations.
+ *
+ * @param message
+ * (string) Error message.
+ */
+function TokenServerClientError(message) {
+ this.name = "TokenServerClientError";
+ this.message = message || "Client error.";
+ // Without explicitly setting .stack, all stacks from these errors will point
+ // to the "new Error()" call a few lines down, which isn't helpful.
+ this.stack = Error().stack;
+}
+TokenServerClientError.prototype = new Error();
+TokenServerClientError.prototype.constructor = TokenServerClientError;
+TokenServerClientError.prototype._toStringFields = function() {
+ return { message: this.message };
+};
+TokenServerClientError.prototype.toString = function() {
+ return this.name + "(" + JSON.stringify(this._toStringFields()) + ")";
+};
+TokenServerClientError.prototype.toJSON = function() {
+ let result = this._toStringFields();
+ result.name = this.name;
+ return result;
+};
+
+/**
+ * Represents a TokenServerClient error that occurred in the network layer.
+ *
+ * @param error
+ * The underlying error thrown by the network layer.
+ */
+function TokenServerClientNetworkError(error) {
+ this.name = "TokenServerClientNetworkError";
+ this.error = error;
+ this.stack = Error().stack;
+}
+TokenServerClientNetworkError.prototype = new TokenServerClientError();
+TokenServerClientNetworkError.prototype.constructor = TokenServerClientNetworkError;
+TokenServerClientNetworkError.prototype._toStringFields = function() {
+ return { error: this.error };
+};
+
+/**
+ * Represents a TokenServerClient error that occurred on the server.
+ *
+ * This type will be encountered for all non-200 response codes from the
+ * server. The type of error is strongly enumerated and is stored in the
+ * `cause` property. This property can have the following string values:
+ *
+ * conditions-required -- The server is requesting that the client
+ * agree to service conditions before it can obtain a token. The
+ * conditions that must be presented to the user and agreed to are in
+ * the `urls` mapping on the instance. Keys of this mapping are
+ * identifiers. Values are string URLs.
+ *
+ * invalid-credentials -- A token could not be obtained because
+ * the credentials presented by the client were invalid.
+ *
+ * unknown-service -- The requested service was not found.
+ *
+ * malformed-request -- The server rejected the request because it
+ * was invalid. If you see this, code in this file is likely wrong.
+ *
+ * malformed-response -- The response from the server was not what was
+ * expected.
+ *
+ * general -- A general server error has occurred. Clients should
+ * interpret this as an opaque failure.
+ *
+ * @param message
+ * (string) Error message.
+ */
+function TokenServerClientServerError(message, cause = "general") {
+ this.now = new Date().toISOString(); // may be useful to diagnose time-skew issues.
+ this.name = "TokenServerClientServerError";
+ this.message = message || "Server error.";
+ this.cause = cause;
+ this.stack = Error().stack;
+}
+TokenServerClientServerError.prototype = new TokenServerClientError();
+TokenServerClientServerError.prototype.constructor = TokenServerClientServerError;
+
+TokenServerClientServerError.prototype._toStringFields = function() {
+ let fields = {
+ now: this.now,
+ message: this.message,
+ cause: this.cause,
+ };
+ if (this.response) {
+ fields.response_body = this.response.body;
+ fields.response_headers = this.response.headers;
+ fields.response_status = this.response.status;
+ }
+ return fields;
+};
+
+/**
+ * Represents a client to the Token Server.
+ *
+ * http://docs.services.mozilla.com/token/index.html
+ *
+ * The Token Server supports obtaining tokens for arbitrary apps by
+ * constructing URI paths of the form <app>/<app_version>. However, the service
+ * discovery mechanism emphasizes the use of full URIs and tries to not force
+ * the client to manipulate URIs. This client currently enforces this practice
+ * by not implementing an API which would perform URI manipulation.
+ *
+ * If you are tempted to implement this API in the future, consider this your
+ * warning that you may be doing it wrong and that you should store full URIs
+ * instead.
+ *
+ * Areas to Improve:
+ *
+ * - The server sends a JSON response on error. The client does not currently
+ * parse this. It might be convenient if it did.
+ * - Currently most non-200 status codes are rolled into one error type. It
+ * might be helpful if callers had a richer API that communicated who was
+ * at fault (e.g. differentiating a 503 from a 401).
+ */
+function TokenServerClient() {
+ this._log = Log.repository.getLogger("Services.Common.TokenServerClient");
+ this._log.manageLevelFromPref(PREF_LOG_LEVEL);
+}
+TokenServerClient.prototype = {
+ /**
+ * Logger instance.
+ */
+ _log: null,
+
+ /**
+ * Obtain a token from a BrowserID assertion against a specific URL.
+ *
+ * This asynchronously obtains the token.
+ * It returns a Promise that resolves or rejects:
+ *
+ * Rejects with:
+ * (TokenServerClientError) If no token could be obtained, this
+ * will be a TokenServerClientError instance describing why. The
+ * type seen defines the type of error encountered. If an HTTP response
+ * was seen, a RESTResponse instance will be stored in the `response`
+ * property of this object. If there was no error and a token is
+ * available, this will be null.
+ *
+ * Resolves with:
+ * (map) On success, this will be a map containing the results from
+ * the server. If there was an error, this will be null. The map has the
+ * following properties:
+ *
+ * id (string) HTTP MAC public key identifier.
+ * key (string) HTTP MAC shared symmetric key.
+ * endpoint (string) URL where service can be connected to.
+ * uid (string) user ID for requested service.
+ * duration (string) the validity duration of the issued token.
+ *
+ * Terms of Service Acceptance
+ * ---------------------------
+ *
+ * Some services require users to accept terms of service before they can
+ * obtain a token. If a service requires ToS acceptance, the error passed
+ * to the callback will be a `TokenServerClientServerError` with the
+ * `cause` property set to "conditions-required". The `urls` property of that
+ * instance will be a map of string keys to string URL values. The user-agent
+ * should prompt the user to accept the content at these URLs.
+ *
+ * Clients signify acceptance of the terms of service by sending a token
+ * request with additional metadata. This is controlled by the
+ * `conditionsAccepted` argument to this function. Clients only need to set
+ * this flag once per service and the server remembers acceptance. If
+ * the conditions for the service change, the server may request
+ * clients agree to terms again. Therefore, clients should always be
+ * prepared to handle a conditions required response.
+ *
+ * Clients should not blindly send acceptance to conditions. Instead, clients
+ * should set `conditionsAccepted` if and only if the server asks for
+ * acceptance, the conditions are displayed to the user, and the user agrees
+ * to them.
+ *
+ * Example Usage
+ * -------------
+ *
+ * let client = new TokenServerClient();
+ * let assertion = getBrowserIDAssertionFromSomewhere();
+ * let url = "https://token.services.mozilla.com/1.0/sync/2.0";
+ *
+ * try {
+ * const result = await client.getTokenFromBrowserIDAssertion(url, assertion);
+ * let {id, key, uid, endpoint, duration} = result;
+ * // Do stuff with data and carry on.
+ * } catch (error) {
+ * // Handle errors.
+ * }
+ *
+ * @param url
+ * (string) URL to fetch token from.
+ * @param assertion
+ * (string) BrowserID assertion to exchange token for.
+ * @param addHeaders
+ * (object) Extra headers for the request.
+ */
+ async getTokenFromBrowserIDAssertion(url, assertion, addHeaders = {}) {
+ this._log.debug("Beginning BID assertion exchange: " + url);
+
+ if (!assertion) {
+ throw new TokenServerClientError("assertion argument is not valid.");
+ }
+
+ return this._tokenServerExchangeRequest(
+ url,
+ `BrowserID ${assertion}`,
+ addHeaders
+ );
+ },
+
+ /**
+ * Obtain a token from a provided OAuth token against a specific URL.
+ *
+ * @param url
+ * (string) URL to fetch token from.
+ * @param oauthToken
+ * (string) FxA OAuth Token to exchange token for.
+ * @param addHeaders
+ * (object) Extra headers for the request.
+ */
+ async getTokenFromOAuthToken(url, oauthToken, addHeaders = {}) {
+ this._log.debug("Beginning OAuth token exchange: " + url);
+
+ if (!oauthToken) {
+ throw new TokenServerClientError("oauthToken argument is not valid.");
+ }
+
+ return this._tokenServerExchangeRequest(
+ url,
+ `Bearer ${oauthToken}`,
+ addHeaders
+ );
+ },
+
+ /**
+ * Performs the exchange request to the token server to
+ * produce a token based on the authorizationHeader input.
+ *
+ * @param url
+ * (string) URL to fetch token from.
+ * @param authorizationHeader
+ * (string) The auth header string that populates the 'Authorization' header.
+ * @param addHeaders
+ * (object) Extra headers for the request.
+ */
+ async _tokenServerExchangeRequest(url, authorizationHeader, addHeaders = {}) {
+ if (!url) {
+ throw new TokenServerClientError("url argument is not valid.");
+ }
+
+ if (!authorizationHeader) {
+ throw new TokenServerClientError(
+ "authorizationHeader argument is not valid."
+ );
+ }
+
+ let req = this.newRESTRequest(url);
+ req.setHeader("Accept", "application/json");
+ req.setHeader("Authorization", authorizationHeader);
+
+ for (let header in addHeaders) {
+ req.setHeader(header, addHeaders[header]);
+ }
+ let response;
+ try {
+ response = await req.get();
+ } catch (err) {
+ throw new TokenServerClientNetworkError(err);
+ }
+
+ try {
+ return this._processTokenResponse(response);
+ } catch (ex) {
+ if (ex instanceof TokenServerClientServerError) {
+ throw ex;
+ }
+ this._log.warn("Error processing token server response", ex);
+ let error = new TokenServerClientError(ex);
+ error.response = response;
+ throw error;
+ }
+ },
+
+ /**
+ * Handler to process token request responses.
+ *
+ * @param response
+ * RESTResponse from token HTTP request.
+ */
+ _processTokenResponse(response) {
+ this._log.debug("Got token response: " + response.status);
+
+ // Responses should *always* be JSON, even in the case of 4xx and 5xx
+ // errors. If we don't see JSON, the server is likely very unhappy.
+ let ct = response.headers["content-type"] || "";
+ if (ct != "application/json" && !ct.startsWith("application/json;")) {
+ this._log.warn("Did not receive JSON response. Misconfigured server?");
+ this._log.debug("Content-Type: " + ct);
+ this._log.debug("Body: " + response.body);
+
+ let error = new TokenServerClientServerError(
+ "Non-JSON response.",
+ "malformed-response"
+ );
+ error.response = response;
+ throw error;
+ }
+
+ let result;
+ try {
+ result = JSON.parse(response.body);
+ } catch (ex) {
+ this._log.warn("Invalid JSON returned by server: " + response.body);
+ let error = new TokenServerClientServerError(
+ "Malformed JSON.",
+ "malformed-response"
+ );
+ error.response = response;
+ throw error;
+ }
+
+ // Any response status can have X-Backoff or X-Weave-Backoff headers.
+ this._maybeNotifyBackoff(response, "x-weave-backoff");
+ this._maybeNotifyBackoff(response, "x-backoff");
+
+ // The service shouldn't have any 3xx, so we don't need to handle those.
+ if (response.status != 200) {
+ // We /should/ have a Cornice error report in the JSON. We log that to
+ // help with debugging.
+ if ("errors" in result) {
+ // This could throw, but this entire function is wrapped in a try. If
+ // the server is sending something not an array of objects, it has
+ // failed to keep its contract with us and there is little we can do.
+ for (let error of result.errors) {
+ this._log.info("Server-reported error: " + JSON.stringify(error));
+ }
+ }
+
+ let error = new TokenServerClientServerError();
+ error.response = response;
+
+ if (response.status == 400) {
+ error.message = "Malformed request.";
+ error.cause = "malformed-request";
+ } else if (response.status == 401) {
+ // Cause can be invalid-credentials, invalid-timestamp, or
+ // invalid-generation.
+ error.message = "Authentication failed.";
+ error.cause = result.status;
+ } else if (response.status == 403) {
+ // 403 should represent a "condition acceptance needed" response.
+ //
+ // The extra validation of "urls" is important. We don't want to signal
+ // conditions required unless we are absolutely sure that is what the
+ // server is asking for.
+ if (!("urls" in result)) {
+ this._log.warn("403 response without proper fields!");
+ this._log.warn("Response body: " + response.body);
+
+ error.message = "Missing JSON fields.";
+ error.cause = "malformed-response";
+ } else if (typeof result.urls != "object") {
+ error.message = "urls field is not a map.";
+ error.cause = "malformed-response";
+ } else {
+ error.message = "Conditions must be accepted.";
+ error.cause = "conditions-required";
+ error.urls = result.urls;
+ }
+ } else if (response.status == 404) {
+ error.message = "Unknown service.";
+ error.cause = "unknown-service";
+ }
+
+ // A Retry-After header should theoretically only appear on a 503, but
+ // we'll look for it on any error response.
+ this._maybeNotifyBackoff(response, "retry-after");
+
+ throw error;
+ }
+
+ for (let k of ["id", "key", "api_endpoint", "uid", "duration"]) {
+ if (!(k in result)) {
+ let error = new TokenServerClientServerError(
+ "Expected key not present in result: " + k
+ );
+ error.cause = "malformed-response";
+ error.response = response;
+ throw error;
+ }
+ }
+
+ this._log.debug("Successful token response");
+ return {
+ id: result.id,
+ key: result.key,
+ endpoint: result.api_endpoint,
+ uid: result.uid,
+ duration: result.duration,
+ hashed_fxa_uid: result.hashed_fxa_uid,
+ node_type: result.node_type,
+ };
+ },
+
+ /*
+ * The prefix used for all notifications sent by this module. This
+ * allows the handler of notifications to be sure they are handling
+ * notifications for the service they expect.
+ *
+ * If not set, no notifications will be sent.
+ */
+ observerPrefix: null,
+
+ // Given an optional header value, notify that a backoff has been requested.
+ _maybeNotifyBackoff(response, headerName) {
+ if (!this.observerPrefix) {
+ return;
+ }
+ let headerVal = response.headers[headerName];
+ if (!headerVal) {
+ return;
+ }
+ let backoffInterval;
+ try {
+ backoffInterval = parseInt(headerVal, 10);
+ } catch (ex) {
+ this._log.error(
+ "TokenServer response had invalid backoff value in '" +
+ headerName +
+ "' header: " +
+ headerVal
+ );
+ return;
+ }
+ Observers.notify(
+ this.observerPrefix + ":backoff:interval",
+ backoffInterval
+ );
+ },
+
+ // override points for testing.
+ newRESTRequest(url) {
+ return new RESTRequest(url);
+ },
+};