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 +}