tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

tokenserverclient.sys.mjs (12991B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
      3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { Log } from "resource://gre/modules/Log.sys.mjs";
      6 
      7 import { RESTRequest } from "resource://services-common/rest.sys.mjs";
      8 import { Observers } from "resource://services-common/observers.sys.mjs";
      9 
     10 const PREF_LOG_LEVEL = "services.common.log.logger.tokenserverclient";
     11 
     12 /**
     13 * Represents a TokenServerClient error that occurred on the client.
     14 *
     15 * This is the base type for all errors raised by client operations.
     16 *
     17 * @param message
     18 *        (string) Error message.
     19 */
     20 export function TokenServerClientError(message) {
     21  this.name = "TokenServerClientError";
     22  this.message = message || "Client error.";
     23  // Without explicitly setting .stack, all stacks from these errors will point
     24  // to the "new Error()" call a few lines down, which isn't helpful.
     25  this.stack = Error().stack;
     26 }
     27 
     28 TokenServerClientError.prototype = new Error();
     29 TokenServerClientError.prototype.constructor = TokenServerClientError;
     30 TokenServerClientError.prototype._toStringFields = function () {
     31  return { message: this.message };
     32 };
     33 TokenServerClientError.prototype.toString = function () {
     34  return this.name + "(" + JSON.stringify(this._toStringFields()) + ")";
     35 };
     36 TokenServerClientError.prototype.toJSON = function () {
     37  let result = this._toStringFields();
     38  result.name = this.name;
     39  return result;
     40 };
     41 
     42 /**
     43 * Represents a TokenServerClient error that occurred in the network layer.
     44 *
     45 * @param error
     46 *        The underlying error thrown by the network layer.
     47 */
     48 export function TokenServerClientNetworkError(error) {
     49  this.name = "TokenServerClientNetworkError";
     50  this.error = error;
     51  this.stack = Error().stack;
     52 }
     53 
     54 TokenServerClientNetworkError.prototype = new TokenServerClientError();
     55 TokenServerClientNetworkError.prototype.constructor =
     56  TokenServerClientNetworkError;
     57 TokenServerClientNetworkError.prototype._toStringFields = function () {
     58  return { error: this.error };
     59 };
     60 
     61 /**
     62 * Represents a TokenServerClient error that occurred on the server.
     63 *
     64 * This type will be encountered for all non-200 response codes from the
     65 * server. The type of error is strongly enumerated and is stored in the
     66 * `cause` property. This property can have the following string values:
     67 *
     68 *   invalid-credentials -- A token could not be obtained because
     69 *     the credentials presented by the client were invalid.
     70 *
     71 *   unknown-service -- The requested service was not found.
     72 *
     73 *   malformed-request -- The server rejected the request because it
     74 *     was invalid. If you see this, code in this file is likely wrong.
     75 *
     76 *   malformed-response -- The response from the server was not what was
     77 *     expected.
     78 *
     79 *   general -- A general server error has occurred. Clients should
     80 *     interpret this as an opaque failure.
     81 *
     82 * @param message
     83 *        (string) Error message.
     84 */
     85 export function TokenServerClientServerError(message, cause = "general") {
     86  this.now = new Date().toISOString(); // may be useful to diagnose time-skew issues.
     87  this.name = "TokenServerClientServerError";
     88  this.message = message || "Server error.";
     89  this.cause = cause;
     90  this.stack = Error().stack;
     91 }
     92 
     93 TokenServerClientServerError.prototype = new TokenServerClientError();
     94 TokenServerClientServerError.prototype.constructor =
     95  TokenServerClientServerError;
     96 
     97 TokenServerClientServerError.prototype._toStringFields = function () {
     98  let fields = {
     99    now: this.now,
    100    message: this.message,
    101    cause: this.cause,
    102  };
    103  if (this.response) {
    104    fields.response_body = this.response.body;
    105    fields.response_headers = this.response.headers;
    106    fields.response_status = this.response.status;
    107  }
    108  return fields;
    109 };
    110 
    111 /**
    112 * Represents a client to the Token Server.
    113 *
    114 * http://docs.services.mozilla.com/token/index.html
    115 *
    116 * The Token Server was designed to support obtaining tokens for arbitrary apps by
    117 * constructing URI paths of the form <app>/<app_version>. In practice this was
    118 * never used and it only supports an <app> value of `sync`, and the API presented
    119 * here reflects that.
    120 *
    121 * Areas to Improve:
    122 *
    123 *  - The server sends a JSON response on error. The client does not currently
    124 *    parse this. It might be convenient if it did.
    125 *  - Currently most non-200 status codes are rolled into one error type. It
    126 *    might be helpful if callers had a richer API that communicated who was
    127 *    at fault (e.g. differentiating a 503 from a 401).
    128 */
    129 export function TokenServerClient() {
    130  this._log = Log.repository.getLogger("Services.Common.TokenServerClient");
    131  this._log.manageLevelFromPref(PREF_LOG_LEVEL);
    132 }
    133 
    134 TokenServerClient.prototype = {
    135  /**
    136   * Logger instance.
    137   */
    138  _log: null,
    139 
    140  /**
    141   * Obtain a token from a provided OAuth token against a specific URL.
    142   *
    143   * This asynchronously obtains the token.
    144   * It returns a Promise that resolves or rejects:
    145   *
    146   *  Rejects with:
    147   *   (TokenServerClientError) If no token could be obtained, this
    148   *     will be a TokenServerClientError instance describing why. The
    149   *     type seen defines the type of error encountered. If an HTTP response
    150   *     was seen, a RESTResponse instance will be stored in the `response`
    151   *     property of this object. If there was no error and a token is
    152   *     available, this will be null.
    153   *
    154   *  Resolves with:
    155   *   (map) On success, this will be a map containing the results from
    156   *     the server. If there was an error, this will be null. The map has the
    157   *     following properties:
    158   *
    159   *       id       (string) HTTP MAC public key identifier.
    160   *       key      (string) HTTP MAC shared symmetric key.
    161   *       endpoint (string) URL where service can be connected to.
    162   *       uid      (string) user ID for requested service.
    163   *       duration (string) the validity duration of the issued token.
    164   *
    165   * Example Usage
    166   * -------------
    167   *
    168   *   let client = new TokenServerClient();
    169   *   let access_token = getOAuthAccessTokenFromSomewhere();
    170   *   let url = "https://token.services.mozilla.com/1.0/sync/2.0";
    171   *
    172   *   try {
    173   *     const result = await client.getTokenUsingOAuth(url, access_token);
    174   *     let {id, key, uid, endpoint, duration} = result;
    175   *     // Do stuff with data and carry on.
    176   *   } catch (error) {
    177   *     // Handle errors.
    178   *   }
    179   * Obtain a token from a provided OAuth token against a specific URL.
    180   *
    181   * @param  url
    182   *         (string) URL to fetch token from.
    183   * @param  oauthToken
    184   *         (string) FxA OAuth Token to exchange token for.
    185   * @param  addHeaders
    186   *         (object) Extra headers for the request.
    187   */
    188  async getTokenUsingOAuth(url, oauthToken, addHeaders = {}) {
    189    this._log.debug("Beginning OAuth token exchange: " + url);
    190 
    191    if (!oauthToken) {
    192      throw new TokenServerClientError("oauthToken argument is not valid.");
    193    }
    194 
    195    return this._tokenServerExchangeRequest(
    196      url,
    197      `Bearer ${oauthToken}`,
    198      addHeaders
    199    );
    200  },
    201 
    202  /**
    203   * Performs the exchange request to the token server to
    204   * produce a token based on the authorizationHeader input.
    205   *
    206   * @param  url
    207   *         (string) URL to fetch token from.
    208   * @param  authorizationHeader
    209   *         (string) The auth header string that populates the 'Authorization' header.
    210   * @param  addHeaders
    211   *         (object) Extra headers for the request.
    212   */
    213  async _tokenServerExchangeRequest(url, authorizationHeader, addHeaders = {}) {
    214    if (!url) {
    215      throw new TokenServerClientError("url argument is not valid.");
    216    }
    217 
    218    if (!authorizationHeader) {
    219      throw new TokenServerClientError(
    220        "authorizationHeader argument is not valid."
    221      );
    222    }
    223 
    224    let req = this.newRESTRequest(url);
    225    req.setHeader("Accept", "application/json");
    226    req.setHeader("Authorization", authorizationHeader);
    227 
    228    for (let header in addHeaders) {
    229      req.setHeader(header, addHeaders[header]);
    230    }
    231    let response;
    232    try {
    233      response = await req.get();
    234    } catch (err) {
    235      throw new TokenServerClientNetworkError(err);
    236    }
    237 
    238    try {
    239      return this._processTokenResponse(response);
    240    } catch (ex) {
    241      if (ex instanceof TokenServerClientServerError) {
    242        throw ex;
    243      }
    244      this._log.warn("Error processing token server response", ex);
    245      let error = new TokenServerClientError(ex);
    246      error.response = response;
    247      throw error;
    248    }
    249  },
    250 
    251  /**
    252   * Handler to process token request responses.
    253   *
    254   * @param response
    255   *        RESTResponse from token HTTP request.
    256   */
    257  _processTokenResponse(response) {
    258    this._log.debug("Got token response: " + response.status);
    259 
    260    // Responses should *always* be JSON, even in the case of 4xx and 5xx
    261    // errors. If we don't see JSON, the server is likely very unhappy.
    262    let ct = response.headers["content-type"] || "";
    263    if (ct != "application/json" && !ct.startsWith("application/json;")) {
    264      this._log.warn("Did not receive JSON response. Misconfigured server?");
    265      this._log.debug("Content-Type: " + ct);
    266      this._log.debug("Body: " + response.body);
    267 
    268      let error = new TokenServerClientServerError(
    269        "Non-JSON response.",
    270        "malformed-response"
    271      );
    272      error.response = response;
    273      throw error;
    274    }
    275 
    276    let result;
    277    try {
    278      result = JSON.parse(response.body);
    279    } catch (ex) {
    280      this._log.warn("Invalid JSON returned by server: " + response.body);
    281      let error = new TokenServerClientServerError(
    282        "Malformed JSON.",
    283        "malformed-response"
    284      );
    285      error.response = response;
    286      throw error;
    287    }
    288 
    289    // Any response status can have X-Backoff or X-Weave-Backoff headers.
    290    this._maybeNotifyBackoff(response, "x-weave-backoff");
    291    this._maybeNotifyBackoff(response, "x-backoff");
    292 
    293    // The service shouldn't have any 3xx, so we don't need to handle those.
    294    if (response.status != 200) {
    295      // We /should/ have a Cornice error report in the JSON. We log that to
    296      // help with debugging.
    297      if ("errors" in result) {
    298        // This could throw, but this entire function is wrapped in a try. If
    299        // the server is sending something not an array of objects, it has
    300        // failed to keep its contract with us and there is little we can do.
    301        for (let error of result.errors) {
    302          this._log.info("Server-reported error: " + JSON.stringify(error));
    303        }
    304      }
    305 
    306      let error = new TokenServerClientServerError();
    307      error.response = response;
    308 
    309      if (response.status == 400) {
    310        error.message = "Malformed request.";
    311        error.cause = "malformed-request";
    312      } else if (response.status == 401) {
    313        // Cause can be invalid-credentials, invalid-timestamp, or
    314        // invalid-generation.
    315        error.message = "Authentication failed.";
    316        error.cause = result.status;
    317      } else if (response.status == 404) {
    318        error.message = "Unknown service.";
    319        error.cause = "unknown-service";
    320      }
    321 
    322      // A Retry-After header should theoretically only appear on a 503, but
    323      // we'll look for it on any error response.
    324      this._maybeNotifyBackoff(response, "retry-after");
    325 
    326      throw error;
    327    }
    328 
    329    for (let k of ["id", "key", "api_endpoint", "uid", "duration"]) {
    330      if (!(k in result)) {
    331        let error = new TokenServerClientServerError(
    332          "Expected key not present in result: " + k
    333        );
    334        error.cause = "malformed-response";
    335        error.response = response;
    336        throw error;
    337      }
    338    }
    339 
    340    this._log.debug("Successful token response");
    341    return {
    342      id: result.id,
    343      key: result.key,
    344      endpoint: result.api_endpoint,
    345      uid: result.uid,
    346      duration: result.duration,
    347      hashed_fxa_uid: result.hashed_fxa_uid,
    348      node_type: result.node_type,
    349    };
    350  },
    351 
    352  /*
    353   * The prefix used for all notifications sent by this module.  This
    354   * allows the handler of notifications to be sure they are handling
    355   * notifications for the service they expect.
    356   *
    357   * If not set, no notifications will be sent.
    358   */
    359  observerPrefix: null,
    360 
    361  // Given an optional header value, notify that a backoff has been requested.
    362  _maybeNotifyBackoff(response, headerName) {
    363    if (!this.observerPrefix) {
    364      return;
    365    }
    366    let headerVal = response.headers[headerName];
    367    if (!headerVal) {
    368      return;
    369    }
    370    let backoffInterval;
    371    try {
    372      backoffInterval = parseInt(headerVal, 10);
    373    } catch (ex) {
    374      this._log.error(
    375        "TokenServer response had invalid backoff value in '" +
    376          headerName +
    377          "' header: " +
    378          headerVal
    379      );
    380      return;
    381    }
    382    Observers.notify(
    383      this.observerPrefix + ":backoff:interval",
    384      backoffInterval
    385    );
    386  },
    387 
    388  // override points for testing.
    389  newRESTRequest(url) {
    390    return new RESTRequest(url);
    391  },
    392 };