services/common/hawkclient.js

changeset 0
6474c204b198
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/services/common/hawkclient.js	Wed Dec 31 06:09:35 2014 +0100
     1.3 @@ -0,0 +1,269 @@
     1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public
     1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this
     1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     1.7 +
     1.8 +"use strict";
     1.9 +
    1.10 +/*
    1.11 + * HAWK is an HTTP authentication scheme using a message authentication code
    1.12 + * (MAC) algorithm to provide partial HTTP request cryptographic verification.
    1.13 + *
    1.14 + * For details, see: https://github.com/hueniverse/hawk
    1.15 + *
    1.16 + * With HAWK, it is essential that the clocks on clients and server not have an
    1.17 + * absolute delta of greater than one minute, as the HAWK protocol uses
    1.18 + * timestamps to reduce the possibility of replay attacks.  However, it is
    1.19 + * likely that some clients' clocks will be more than a little off, especially
    1.20 + * in mobile devices, which would break HAWK-based services (like sync and
    1.21 + * firefox accounts) for those clients.
    1.22 + *
    1.23 + * This library provides a stateful HAWK client that calculates (roughly) the
    1.24 + * clock delta on the client vs the server.  The library provides an interface
    1.25 + * for deriving HAWK credentials and making HAWK-authenticated REST requests to
    1.26 + * a single remote server.  Therefore, callers who want to interact with
    1.27 + * multiple HAWK services should instantiate one HawkClient per service.
    1.28 + */
    1.29 +
    1.30 +this.EXPORTED_SYMBOLS = ["HawkClient"];
    1.31 +
    1.32 +const {interfaces: Ci, utils: Cu} = Components;
    1.33 +
    1.34 +Cu.import("resource://gre/modules/FxAccountsCommon.js");
    1.35 +Cu.import("resource://services-common/utils.js");
    1.36 +Cu.import("resource://services-crypto/utils.js");
    1.37 +Cu.import("resource://services-common/hawkrequest.js");
    1.38 +Cu.import("resource://services-common/observers.js");
    1.39 +Cu.import("resource://gre/modules/Promise.jsm");
    1.40 +
    1.41 +/*
    1.42 + * A general purpose client for making HAWK authenticated requests to a single
    1.43 + * host.  Keeps track of the clock offset between the client and the host for
    1.44 + * computation of the timestamp in the HAWK Authorization header.
    1.45 + *
    1.46 + * Clients should create one HawkClient object per each server they wish to
    1.47 + * interact with.
    1.48 + *
    1.49 + * @param host
    1.50 + *        The url of the host
    1.51 + */
    1.52 +this.HawkClient = function(host) {
    1.53 +  this.host = host;
    1.54 +
    1.55 +  // Clock offset in milliseconds between our client's clock and the date
    1.56 +  // reported in responses from our host.
    1.57 +  this._localtimeOffsetMsec = 0;
    1.58 +}
    1.59 +
    1.60 +this.HawkClient.prototype = {
    1.61 +
    1.62 +  /*
    1.63 +   * Construct an error message for a response.  Private.
    1.64 +   *
    1.65 +   * @param restResponse
    1.66 +   *        A RESTResponse object from a RESTRequest
    1.67 +   *
    1.68 +   * @param errorString
    1.69 +   *        A string describing the error
    1.70 +   */
    1.71 +  _constructError: function(restResponse, errorString) {
    1.72 +    let errorObj = {
    1.73 +      error: errorString,
    1.74 +      message: restResponse.statusText,
    1.75 +      code: restResponse.status,
    1.76 +      errno: restResponse.status
    1.77 +    };
    1.78 +    let retryAfter = restResponse.headers && restResponse.headers["retry-after"];
    1.79 +    retryAfter = retryAfter ? parseInt(retryAfter) : retryAfter;
    1.80 +    if (retryAfter) {
    1.81 +      errorObj.retryAfter = retryAfter;
    1.82 +      // and notify observers of the retry interval
    1.83 +      if (this.observerPrefix) {
    1.84 +        Observers.notify(this.observerPrefix + ":backoff:interval", retryAfter);
    1.85 +      }
    1.86 +    }
    1.87 +    return errorObj;
    1.88 +  },
    1.89 +
    1.90 +  /*
    1.91 +   *
    1.92 +   * Update clock offset by determining difference from date gives in the (RFC
    1.93 +   * 1123) Date header of a server response.  Because HAWK tolerates a window
    1.94 +   * of one minute of clock skew (so two minutes total since the skew can be
    1.95 +   * positive or negative), the simple method of calculating offset here is
    1.96 +   * probably good enough.  We keep the value in milliseconds to make life
    1.97 +   * easier, even though the value will not have millisecond accuracy.
    1.98 +   *
    1.99 +   * @param dateString
   1.100 +   *        An RFC 1123 date string (e.g., "Mon, 13 Jan 2014 21:45:06 GMT")
   1.101 +   *
   1.102 +   * For HAWK clock skew and replay protection, see
   1.103 +   * https://github.com/hueniverse/hawk#replay-protection
   1.104 +   */
   1.105 +  _updateClockOffset: function(dateString) {
   1.106 +    try {
   1.107 +      let serverDateMsec = Date.parse(dateString);
   1.108 +      this._localtimeOffsetMsec = serverDateMsec - this.now();
   1.109 +      log.debug("Clock offset vs " + this.host + ": " + this._localtimeOffsetMsec);
   1.110 +    } catch(err) {
   1.111 +      log.warn("Bad date header in server response: " + dateString);
   1.112 +    }
   1.113 +  },
   1.114 +
   1.115 +  /*
   1.116 +   * Get the current clock offset in milliseconds.
   1.117 +   *
   1.118 +   * The offset is the number of milliseconds that must be added to the client
   1.119 +   * clock to make it equal to the server clock.  For example, if the client is
   1.120 +   * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
   1.121 +   */
   1.122 +  get localtimeOffsetMsec() {
   1.123 +    return this._localtimeOffsetMsec;
   1.124 +  },
   1.125 +
   1.126 +  /*
   1.127 +   * return current time in milliseconds
   1.128 +   */
   1.129 +  now: function() {
   1.130 +    return Date.now();
   1.131 +  },
   1.132 +
   1.133 +  /* A general method for sending raw RESTRequest calls authorized using HAWK
   1.134 +   *
   1.135 +   * @param path
   1.136 +   *        API endpoint path
   1.137 +   * @param method
   1.138 +   *        The HTTP request method
   1.139 +   * @param credentials
   1.140 +   *        Hawk credentials
   1.141 +   * @param payloadObj
   1.142 +   *        An object that can be encodable as JSON as the payload of the
   1.143 +   *        request
   1.144 +   * @return Promise
   1.145 +   *        Returns a promise that resolves to the text response of the API call,
   1.146 +   *        or is rejected with an error.  If the server response can be parsed
   1.147 +   *        as JSON and contains an 'error' property, the promise will be
   1.148 +   *        rejected with this JSON-parsed response.
   1.149 +   */
   1.150 +  request: function(path, method, credentials=null, payloadObj={}, retryOK=true) {
   1.151 +    method = method.toLowerCase();
   1.152 +
   1.153 +    let deferred = Promise.defer();
   1.154 +    let uri = this.host + path;
   1.155 +    let self = this;
   1.156 +
   1.157 +    function _onComplete(error) {
   1.158 +      let restResponse = this.response;
   1.159 +      let status = restResponse.status;
   1.160 +
   1.161 +      log.debug("(Response) " + path + ": code: " + status +
   1.162 +                " - Status text: " + restResponse.statusText);
   1.163 +      if (logPII) {
   1.164 +        log.debug("Response text: " + restResponse.body);
   1.165 +      }
   1.166 +
   1.167 +      // All responses may have backoff headers, which are a server-side safety
   1.168 +      // valve to allow slowing down clients without hurting performance.
   1.169 +      self._maybeNotifyBackoff(restResponse, "x-weave-backoff");
   1.170 +      self._maybeNotifyBackoff(restResponse, "x-backoff");
   1.171 +
   1.172 +      if (error) {
   1.173 +        // When things really blow up, reconstruct an error object that follows
   1.174 +        // the general format of the server on error responses.
   1.175 +        return deferred.reject(self._constructError(restResponse, error));
   1.176 +      }
   1.177 +
   1.178 +      self._updateClockOffset(restResponse.headers["date"]);
   1.179 +
   1.180 +      if (status === 401 && retryOK && !("retry-after" in restResponse.headers)) {
   1.181 +        // Retry once if we were rejected due to a bad timestamp.
   1.182 +        // Clock offset is adjusted already in the top of this function.
   1.183 +        log.debug("Received 401 for " + path + ": retrying");
   1.184 +        return deferred.resolve(
   1.185 +            self.request(path, method, credentials, payloadObj, false));
   1.186 +      }
   1.187 +
   1.188 +      // If the server returned a json error message, use it in the rejection
   1.189 +      // of the promise.
   1.190 +      //
   1.191 +      // In the case of a 401, in which we are probably being rejected for a
   1.192 +      // bad timestamp, retry exactly once, during which time clock offset will
   1.193 +      // be adjusted.
   1.194 +
   1.195 +      let jsonResponse = {};
   1.196 +      try {
   1.197 +        jsonResponse = JSON.parse(restResponse.body);
   1.198 +      } catch(notJSON) {}
   1.199 +
   1.200 +      let okResponse = (200 <= status && status < 300);
   1.201 +      if (!okResponse || jsonResponse.error) {
   1.202 +        if (jsonResponse.error) {
   1.203 +          return deferred.reject(jsonResponse);
   1.204 +        }
   1.205 +        return deferred.reject(self._constructError(restResponse, "Request failed"));
   1.206 +      }
   1.207 +      // It's up to the caller to know how to decode the response.
   1.208 +      // We just return the raw text.
   1.209 +      deferred.resolve(this.response.body);
   1.210 +    };
   1.211 +
   1.212 +    function onComplete(error) {
   1.213 +      try {
   1.214 +        // |this| is the RESTRequest object and we need to ensure _onComplete
   1.215 +        // gets the same one.
   1.216 +        _onComplete.call(this, error);
   1.217 +      } catch (ex) {
   1.218 +        log.error("Unhandled exception processing response:" +
   1.219 +                  CommonUtils.exceptionStr(ex));
   1.220 +        deferred.reject(ex);
   1.221 +      }
   1.222 +    }
   1.223 +
   1.224 +    let extra = {
   1.225 +      now: this.now(),
   1.226 +      localtimeOffsetMsec: this.localtimeOffsetMsec,
   1.227 +    };
   1.228 +
   1.229 +    let request = this.newHAWKAuthenticatedRESTRequest(uri, credentials, extra);
   1.230 +    if (method == "post" || method == "put") {
   1.231 +      request[method](payloadObj, onComplete);
   1.232 +    } else {
   1.233 +      request[method](onComplete);
   1.234 +    }
   1.235 +
   1.236 +    return deferred.promise;
   1.237 +  },
   1.238 +
   1.239 +  /*
   1.240 +   * The prefix used for all notifications sent by this module.  This
   1.241 +   * allows the handler of notifications to be sure they are handling
   1.242 +   * notifications for the service they expect.
   1.243 +   *
   1.244 +   * If not set, no notifications will be sent.
   1.245 +   */
   1.246 +  observerPrefix: null,
   1.247 +
   1.248 +  // Given an optional header value, notify that a backoff has been requested.
   1.249 +  _maybeNotifyBackoff: function (response, headerName) {
   1.250 +    if (!this.observerPrefix || !response.headers) {
   1.251 +      return;
   1.252 +    }
   1.253 +    let headerVal = response.headers[headerName];
   1.254 +    if (!headerVal) {
   1.255 +      return;
   1.256 +    }
   1.257 +    let backoffInterval;
   1.258 +    try {
   1.259 +      backoffInterval = parseInt(headerVal, 10);
   1.260 +    } catch (ex) {
   1.261 +      log.error("hawkclient response had invalid backoff value in '" +
   1.262 +                headerName + "' header: " + headerVal);
   1.263 +      return;
   1.264 +    }
   1.265 +    Observers.notify(this.observerPrefix + ":backoff:interval", backoffInterval);
   1.266 +  },
   1.267 +
   1.268 +  // override points for testing.
   1.269 +  newHAWKAuthenticatedRESTRequest: function(uri, credentials, extra) {
   1.270 +    return new HAWKAuthenticatedRESTRequest(uri, credentials, extra);
   1.271 +  },
   1.272 +}

mercurial