michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: /* michael@0: * HAWK is an HTTP authentication scheme using a message authentication code michael@0: * (MAC) algorithm to provide partial HTTP request cryptographic verification. michael@0: * michael@0: * For details, see: https://github.com/hueniverse/hawk michael@0: * michael@0: * With HAWK, it is essential that the clocks on clients and server not have an michael@0: * absolute delta of greater than one minute, as the HAWK protocol uses michael@0: * timestamps to reduce the possibility of replay attacks. However, it is michael@0: * likely that some clients' clocks will be more than a little off, especially michael@0: * in mobile devices, which would break HAWK-based services (like sync and michael@0: * firefox accounts) for those clients. michael@0: * michael@0: * This library provides a stateful HAWK client that calculates (roughly) the michael@0: * clock delta on the client vs the server. The library provides an interface michael@0: * for deriving HAWK credentials and making HAWK-authenticated REST requests to michael@0: * a single remote server. Therefore, callers who want to interact with michael@0: * multiple HAWK services should instantiate one HawkClient per service. michael@0: */ michael@0: michael@0: this.EXPORTED_SYMBOLS = ["HawkClient"]; michael@0: michael@0: const {interfaces: Ci, utils: Cu} = Components; michael@0: michael@0: Cu.import("resource://gre/modules/FxAccountsCommon.js"); michael@0: Cu.import("resource://services-common/utils.js"); michael@0: Cu.import("resource://services-crypto/utils.js"); michael@0: Cu.import("resource://services-common/hawkrequest.js"); michael@0: Cu.import("resource://services-common/observers.js"); michael@0: Cu.import("resource://gre/modules/Promise.jsm"); michael@0: michael@0: /* michael@0: * A general purpose client for making HAWK authenticated requests to a single michael@0: * host. Keeps track of the clock offset between the client and the host for michael@0: * computation of the timestamp in the HAWK Authorization header. michael@0: * michael@0: * Clients should create one HawkClient object per each server they wish to michael@0: * interact with. michael@0: * michael@0: * @param host michael@0: * The url of the host michael@0: */ michael@0: this.HawkClient = function(host) { michael@0: this.host = host; michael@0: michael@0: // Clock offset in milliseconds between our client's clock and the date michael@0: // reported in responses from our host. michael@0: this._localtimeOffsetMsec = 0; michael@0: } michael@0: michael@0: this.HawkClient.prototype = { michael@0: michael@0: /* michael@0: * Construct an error message for a response. Private. michael@0: * michael@0: * @param restResponse michael@0: * A RESTResponse object from a RESTRequest michael@0: * michael@0: * @param errorString michael@0: * A string describing the error michael@0: */ michael@0: _constructError: function(restResponse, errorString) { michael@0: let errorObj = { michael@0: error: errorString, michael@0: message: restResponse.statusText, michael@0: code: restResponse.status, michael@0: errno: restResponse.status michael@0: }; michael@0: let retryAfter = restResponse.headers && restResponse.headers["retry-after"]; michael@0: retryAfter = retryAfter ? parseInt(retryAfter) : retryAfter; michael@0: if (retryAfter) { michael@0: errorObj.retryAfter = retryAfter; michael@0: // and notify observers of the retry interval michael@0: if (this.observerPrefix) { michael@0: Observers.notify(this.observerPrefix + ":backoff:interval", retryAfter); michael@0: } michael@0: } michael@0: return errorObj; michael@0: }, michael@0: michael@0: /* michael@0: * michael@0: * Update clock offset by determining difference from date gives in the (RFC michael@0: * 1123) Date header of a server response. Because HAWK tolerates a window michael@0: * of one minute of clock skew (so two minutes total since the skew can be michael@0: * positive or negative), the simple method of calculating offset here is michael@0: * probably good enough. We keep the value in milliseconds to make life michael@0: * easier, even though the value will not have millisecond accuracy. michael@0: * michael@0: * @param dateString michael@0: * An RFC 1123 date string (e.g., "Mon, 13 Jan 2014 21:45:06 GMT") michael@0: * michael@0: * For HAWK clock skew and replay protection, see michael@0: * https://github.com/hueniverse/hawk#replay-protection michael@0: */ michael@0: _updateClockOffset: function(dateString) { michael@0: try { michael@0: let serverDateMsec = Date.parse(dateString); michael@0: this._localtimeOffsetMsec = serverDateMsec - this.now(); michael@0: log.debug("Clock offset vs " + this.host + ": " + this._localtimeOffsetMsec); michael@0: } catch(err) { michael@0: log.warn("Bad date header in server response: " + dateString); michael@0: } michael@0: }, michael@0: michael@0: /* michael@0: * Get the current clock offset in milliseconds. michael@0: * michael@0: * The offset is the number of milliseconds that must be added to the client michael@0: * clock to make it equal to the server clock. For example, if the client is michael@0: * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. michael@0: */ michael@0: get localtimeOffsetMsec() { michael@0: return this._localtimeOffsetMsec; michael@0: }, michael@0: michael@0: /* michael@0: * return current time in milliseconds michael@0: */ michael@0: now: function() { michael@0: return Date.now(); michael@0: }, michael@0: michael@0: /* A general method for sending raw RESTRequest calls authorized using HAWK michael@0: * michael@0: * @param path michael@0: * API endpoint path michael@0: * @param method michael@0: * The HTTP request method michael@0: * @param credentials michael@0: * Hawk credentials michael@0: * @param payloadObj michael@0: * An object that can be encodable as JSON as the payload of the michael@0: * request michael@0: * @return Promise michael@0: * Returns a promise that resolves to the text response of the API call, michael@0: * or is rejected with an error. If the server response can be parsed michael@0: * as JSON and contains an 'error' property, the promise will be michael@0: * rejected with this JSON-parsed response. michael@0: */ michael@0: request: function(path, method, credentials=null, payloadObj={}, retryOK=true) { michael@0: method = method.toLowerCase(); michael@0: michael@0: let deferred = Promise.defer(); michael@0: let uri = this.host + path; michael@0: let self = this; michael@0: michael@0: function _onComplete(error) { michael@0: let restResponse = this.response; michael@0: let status = restResponse.status; michael@0: michael@0: log.debug("(Response) " + path + ": code: " + status + michael@0: " - Status text: " + restResponse.statusText); michael@0: if (logPII) { michael@0: log.debug("Response text: " + restResponse.body); michael@0: } michael@0: michael@0: // All responses may have backoff headers, which are a server-side safety michael@0: // valve to allow slowing down clients without hurting performance. michael@0: self._maybeNotifyBackoff(restResponse, "x-weave-backoff"); michael@0: self._maybeNotifyBackoff(restResponse, "x-backoff"); michael@0: michael@0: if (error) { michael@0: // When things really blow up, reconstruct an error object that follows michael@0: // the general format of the server on error responses. michael@0: return deferred.reject(self._constructError(restResponse, error)); michael@0: } michael@0: michael@0: self._updateClockOffset(restResponse.headers["date"]); michael@0: michael@0: if (status === 401 && retryOK && !("retry-after" in restResponse.headers)) { michael@0: // Retry once if we were rejected due to a bad timestamp. michael@0: // Clock offset is adjusted already in the top of this function. michael@0: log.debug("Received 401 for " + path + ": retrying"); michael@0: return deferred.resolve( michael@0: self.request(path, method, credentials, payloadObj, false)); michael@0: } michael@0: michael@0: // If the server returned a json error message, use it in the rejection michael@0: // of the promise. michael@0: // michael@0: // In the case of a 401, in which we are probably being rejected for a michael@0: // bad timestamp, retry exactly once, during which time clock offset will michael@0: // be adjusted. michael@0: michael@0: let jsonResponse = {}; michael@0: try { michael@0: jsonResponse = JSON.parse(restResponse.body); michael@0: } catch(notJSON) {} michael@0: michael@0: let okResponse = (200 <= status && status < 300); michael@0: if (!okResponse || jsonResponse.error) { michael@0: if (jsonResponse.error) { michael@0: return deferred.reject(jsonResponse); michael@0: } michael@0: return deferred.reject(self._constructError(restResponse, "Request failed")); michael@0: } michael@0: // It's up to the caller to know how to decode the response. michael@0: // We just return the raw text. michael@0: deferred.resolve(this.response.body); michael@0: }; michael@0: michael@0: function onComplete(error) { michael@0: try { michael@0: // |this| is the RESTRequest object and we need to ensure _onComplete michael@0: // gets the same one. michael@0: _onComplete.call(this, error); michael@0: } catch (ex) { michael@0: log.error("Unhandled exception processing response:" + michael@0: CommonUtils.exceptionStr(ex)); michael@0: deferred.reject(ex); michael@0: } michael@0: } michael@0: michael@0: let extra = { michael@0: now: this.now(), michael@0: localtimeOffsetMsec: this.localtimeOffsetMsec, michael@0: }; michael@0: michael@0: let request = this.newHAWKAuthenticatedRESTRequest(uri, credentials, extra); michael@0: if (method == "post" || method == "put") { michael@0: request[method](payloadObj, onComplete); michael@0: } else { michael@0: request[method](onComplete); michael@0: } michael@0: michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: /* michael@0: * The prefix used for all notifications sent by this module. This michael@0: * allows the handler of notifications to be sure they are handling michael@0: * notifications for the service they expect. michael@0: * michael@0: * If not set, no notifications will be sent. michael@0: */ michael@0: observerPrefix: null, michael@0: michael@0: // Given an optional header value, notify that a backoff has been requested. michael@0: _maybeNotifyBackoff: function (response, headerName) { michael@0: if (!this.observerPrefix || !response.headers) { michael@0: return; michael@0: } michael@0: let headerVal = response.headers[headerName]; michael@0: if (!headerVal) { michael@0: return; michael@0: } michael@0: let backoffInterval; michael@0: try { michael@0: backoffInterval = parseInt(headerVal, 10); michael@0: } catch (ex) { michael@0: log.error("hawkclient response had invalid backoff value in '" + michael@0: headerName + "' header: " + headerVal); michael@0: return; michael@0: } michael@0: Observers.notify(this.observerPrefix + ":backoff:interval", backoffInterval); michael@0: }, michael@0: michael@0: // override points for testing. michael@0: newHAWKAuthenticatedRESTRequest: function(uri, credentials, extra) { michael@0: return new HAWKAuthenticatedRESTRequest(uri, credentials, extra); michael@0: }, michael@0: }