diff options
Diffstat (limited to 'services/common/hawkclient.js')
-rw-r--r-- | services/common/hawkclient.js | 346 |
1 files changed, 346 insertions, 0 deletions
diff --git a/services/common/hawkclient.js b/services/common/hawkclient.js new file mode 100644 index 000000000..88e9c2f2d --- /dev/null +++ b/services/common/hawkclient.js @@ -0,0 +1,346 @@ +/* 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"; + +/* + * HAWK is an HTTP authentication scheme using a message authentication code + * (MAC) algorithm to provide partial HTTP request cryptographic verification. + * + * For details, see: https://github.com/hueniverse/hawk + * + * With HAWK, it is essential that the clocks on clients and server not have an + * absolute delta of greater than one minute, as the HAWK protocol uses + * timestamps to reduce the possibility of replay attacks. However, it is + * likely that some clients' clocks will be more than a little off, especially + * in mobile devices, which would break HAWK-based services (like sync and + * firefox accounts) for those clients. + * + * This library provides a stateful HAWK client that calculates (roughly) the + * clock delta on the client vs the server. The library provides an interface + * for deriving HAWK credentials and making HAWK-authenticated REST requests to + * a single remote server. Therefore, callers who want to interact with + * multiple HAWK services should instantiate one HawkClient per service. + */ + +this.EXPORTED_SYMBOLS = ["HawkClient"]; + +var {interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://services-crypto/utils.js"); +Cu.import("resource://services-common/hawkrequest.js"); +Cu.import("resource://services-common/observers.js"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +// log.appender.dump should be one of "Fatal", "Error", "Warn", "Info", "Config", +// "Debug", "Trace" or "All". If none is specified, "Error" will be used by +// default. +// Note however that Sync will also add this log to *its* DumpAppender, so +// in a Sync context it shouldn't be necessary to adjust this - however, that +// also means error logs are likely to be dump'd twice but that's OK. +const PREF_LOG_LEVEL = "services.common.hawk.log.appender.dump"; + +// A pref that can be set so "sensitive" information (eg, personally +// identifiable info, credentials, etc) will be logged. +const PREF_LOG_SENSITIVE_DETAILS = "services.common.hawk.log.sensitive"; + +XPCOMUtils.defineLazyGetter(this, "log", function() { + let log = Log.repository.getLogger("Hawk"); + // We set the log itself to "debug" and set the level from the preference to + // the appender. This allows other things to send the logs to different + // appenders, while still allowing the pref to control what is seen via dump() + log.level = Log.Level.Debug; + let appender = new Log.DumpAppender(); + log.addAppender(appender); + appender.level = Log.Level.Error; + try { + let level = + Services.prefs.getPrefType(PREF_LOG_LEVEL) == Ci.nsIPrefBranch.PREF_STRING + && Services.prefs.getCharPref(PREF_LOG_LEVEL); + appender.level = Log.Level[level] || Log.Level.Error; + } catch (e) { + log.error(e); + } + + return log; +}); + +// A boolean to indicate if personally identifiable information (or anything +// else sensitive, such as credentials) should be logged. +XPCOMUtils.defineLazyGetter(this, 'logPII', function() { + try { + return Services.prefs.getBoolPref(PREF_LOG_SENSITIVE_DETAILS); + } catch (_) { + return false; + } +}); + +/* + * A general purpose client for making HAWK authenticated requests to a single + * host. Keeps track of the clock offset between the client and the host for + * computation of the timestamp in the HAWK Authorization header. + * + * Clients should create one HawkClient object per each server they wish to + * interact with. + * + * @param host + * The url of the host + */ +this.HawkClient = function(host) { + this.host = host; + + // Clock offset in milliseconds between our client's clock and the date + // reported in responses from our host. + this._localtimeOffsetMsec = 0; +} + +this.HawkClient.prototype = { + + /* + * A boolean for feature detection. + */ + willUTF8EncodeRequests: HAWKAuthenticatedRESTRequest.prototype.willUTF8EncodeObjectRequests, + + /* + * Construct an error message for a response. Private. + * + * @param restResponse + * A RESTResponse object from a RESTRequest + * + * @param error + * A string or object describing the error + */ + _constructError: function(restResponse, error) { + let errorObj = { + error: error, + // This object is likely to be JSON.stringify'd, but neither Error() + // objects nor Components.Exception objects do the right thing there, + // so we add a new element which is simply the .toString() version of + // the error object, so it does appear in JSON'd values. + errorString: error.toString(), + message: restResponse.statusText, + code: restResponse.status, + errno: restResponse.status, + toString() { + return this.code + ": " + this.message; + }, + }; + let retryAfter = restResponse.headers && restResponse.headers["retry-after"]; + retryAfter = retryAfter ? parseInt(retryAfter) : retryAfter; + if (retryAfter) { + errorObj.retryAfter = retryAfter; + // and notify observers of the retry interval + if (this.observerPrefix) { + Observers.notify(this.observerPrefix + ":backoff:interval", retryAfter); + } + } + return errorObj; + }, + + /* + * + * Update clock offset by determining difference from date gives in the (RFC + * 1123) Date header of a server response. Because HAWK tolerates a window + * of one minute of clock skew (so two minutes total since the skew can be + * positive or negative), the simple method of calculating offset here is + * probably good enough. We keep the value in milliseconds to make life + * easier, even though the value will not have millisecond accuracy. + * + * @param dateString + * An RFC 1123 date string (e.g., "Mon, 13 Jan 2014 21:45:06 GMT") + * + * For HAWK clock skew and replay protection, see + * https://github.com/hueniverse/hawk#replay-protection + */ + _updateClockOffset: function(dateString) { + try { + let serverDateMsec = Date.parse(dateString); + this._localtimeOffsetMsec = serverDateMsec - this.now(); + log.debug("Clock offset vs " + this.host + ": " + this._localtimeOffsetMsec); + } catch(err) { + log.warn("Bad date header in server response: " + dateString); + } + }, + + /* + * Get the current clock offset in milliseconds. + * + * The offset is the number of milliseconds that must be added to the client + * clock to make it equal to the server clock. For example, if the client is + * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. + */ + get localtimeOffsetMsec() { + return this._localtimeOffsetMsec; + }, + + /* + * return current time in milliseconds + */ + now: function() { + return Date.now(); + }, + + /* A general method for sending raw RESTRequest calls authorized using HAWK + * + * @param path + * API endpoint path + * @param method + * The HTTP request method + * @param credentials + * Hawk credentials + * @param payloadObj + * An object that can be encodable as JSON as the payload of the + * request + * @param extraHeaders + * An object with header/value pairs to send with the request. + * @return Promise + * Returns a promise that resolves to the response of the API call, + * or is rejected with an error. If the server response can be parsed + * as JSON and contains an 'error' property, the promise will be + * rejected with this JSON-parsed response. + */ + request: function(path, method, credentials=null, payloadObj={}, extraHeaders = {}, + retryOK=true) { + method = method.toLowerCase(); + + let deferred = Promise.defer(); + let uri = this.host + path; + let self = this; + + function _onComplete(error) { + // |error| can be either a normal caught error or an explicitly created + // Components.Exception() error. Log it now as it might not end up + // correctly in the logs by the time it's passed through _constructError. + if (error) { + log.warn("hawk request error", error); + } + // If there's no response there's nothing else to do. + if (!this.response) { + deferred.reject(error); + return; + } + let restResponse = this.response; + let status = restResponse.status; + + log.debug("(Response) " + path + ": code: " + status + + " - Status text: " + restResponse.statusText); + if (logPII) { + log.debug("Response text: " + restResponse.body); + } + + // All responses may have backoff headers, which are a server-side safety + // valve to allow slowing down clients without hurting performance. + self._maybeNotifyBackoff(restResponse, "x-weave-backoff"); + self._maybeNotifyBackoff(restResponse, "x-backoff"); + + if (error) { + // When things really blow up, reconstruct an error object that follows + // the general format of the server on error responses. + return deferred.reject(self._constructError(restResponse, error)); + } + + self._updateClockOffset(restResponse.headers["date"]); + + if (status === 401 && retryOK && !("retry-after" in restResponse.headers)) { + // Retry once if we were rejected due to a bad timestamp. + // Clock offset is adjusted already in the top of this function. + log.debug("Received 401 for " + path + ": retrying"); + return deferred.resolve( + self.request(path, method, credentials, payloadObj, extraHeaders, false)); + } + + // If the server returned a json error message, use it in the rejection + // of the promise. + // + // In the case of a 401, in which we are probably being rejected for a + // bad timestamp, retry exactly once, during which time clock offset will + // be adjusted. + + let jsonResponse = {}; + try { + jsonResponse = JSON.parse(restResponse.body); + } catch(notJSON) {} + + let okResponse = (200 <= status && status < 300); + if (!okResponse || jsonResponse.error) { + if (jsonResponse.error) { + return deferred.reject(jsonResponse); + } + return deferred.reject(self._constructError(restResponse, "Request failed")); + } + // It's up to the caller to know how to decode the response. + // We just return the whole response. + deferred.resolve(this.response); + }; + + function onComplete(error) { + try { + // |this| is the RESTRequest object and we need to ensure _onComplete + // gets the same one. + _onComplete.call(this, error); + } catch (ex) { + log.error("Unhandled exception processing response", ex); + deferred.reject(ex); + } + } + + let extra = { + now: this.now(), + localtimeOffsetMsec: this.localtimeOffsetMsec, + headers: extraHeaders + }; + + let request = this.newHAWKAuthenticatedRESTRequest(uri, credentials, extra); + try { + if (method == "post" || method == "put" || method == "patch") { + request[method](payloadObj, onComplete); + } else { + request[method](onComplete); + } + } catch (ex) { + log.error("Failed to make hawk request", ex); + deferred.reject(ex); + } + + return deferred.promise; + }, + + /* + * 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: function (response, headerName) { + if (!this.observerPrefix || !response.headers) { + return; + } + let headerVal = response.headers[headerName]; + if (!headerVal) { + return; + } + let backoffInterval; + try { + backoffInterval = parseInt(headerVal, 10); + } catch (ex) { + log.error("hawkclient response had invalid backoff value in '" + + headerName + "' header: " + headerVal); + return; + } + Observers.notify(this.observerPrefix + ":backoff:interval", backoffInterval); + }, + + // override points for testing. + newHAWKAuthenticatedRESTRequest: function(uri, credentials, extra) { + return new HAWKAuthenticatedRESTRequest(uri, credentials, extra); + }, + +} |