Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
michael@0 | 1 | /* This Source Code Form is subject to the terms of the Mozilla Public |
michael@0 | 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this |
michael@0 | 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
michael@0 | 4 | |
michael@0 | 5 | "use strict"; |
michael@0 | 6 | |
michael@0 | 7 | /* |
michael@0 | 8 | * HAWK is an HTTP authentication scheme using a message authentication code |
michael@0 | 9 | * (MAC) algorithm to provide partial HTTP request cryptographic verification. |
michael@0 | 10 | * |
michael@0 | 11 | * For details, see: https://github.com/hueniverse/hawk |
michael@0 | 12 | * |
michael@0 | 13 | * With HAWK, it is essential that the clocks on clients and server not have an |
michael@0 | 14 | * absolute delta of greater than one minute, as the HAWK protocol uses |
michael@0 | 15 | * timestamps to reduce the possibility of replay attacks. However, it is |
michael@0 | 16 | * likely that some clients' clocks will be more than a little off, especially |
michael@0 | 17 | * in mobile devices, which would break HAWK-based services (like sync and |
michael@0 | 18 | * firefox accounts) for those clients. |
michael@0 | 19 | * |
michael@0 | 20 | * This library provides a stateful HAWK client that calculates (roughly) the |
michael@0 | 21 | * clock delta on the client vs the server. The library provides an interface |
michael@0 | 22 | * for deriving HAWK credentials and making HAWK-authenticated REST requests to |
michael@0 | 23 | * a single remote server. Therefore, callers who want to interact with |
michael@0 | 24 | * multiple HAWK services should instantiate one HawkClient per service. |
michael@0 | 25 | */ |
michael@0 | 26 | |
michael@0 | 27 | this.EXPORTED_SYMBOLS = ["HawkClient"]; |
michael@0 | 28 | |
michael@0 | 29 | const {interfaces: Ci, utils: Cu} = Components; |
michael@0 | 30 | |
michael@0 | 31 | Cu.import("resource://gre/modules/FxAccountsCommon.js"); |
michael@0 | 32 | Cu.import("resource://services-common/utils.js"); |
michael@0 | 33 | Cu.import("resource://services-crypto/utils.js"); |
michael@0 | 34 | Cu.import("resource://services-common/hawkrequest.js"); |
michael@0 | 35 | Cu.import("resource://services-common/observers.js"); |
michael@0 | 36 | Cu.import("resource://gre/modules/Promise.jsm"); |
michael@0 | 37 | |
michael@0 | 38 | /* |
michael@0 | 39 | * A general purpose client for making HAWK authenticated requests to a single |
michael@0 | 40 | * host. Keeps track of the clock offset between the client and the host for |
michael@0 | 41 | * computation of the timestamp in the HAWK Authorization header. |
michael@0 | 42 | * |
michael@0 | 43 | * Clients should create one HawkClient object per each server they wish to |
michael@0 | 44 | * interact with. |
michael@0 | 45 | * |
michael@0 | 46 | * @param host |
michael@0 | 47 | * The url of the host |
michael@0 | 48 | */ |
michael@0 | 49 | this.HawkClient = function(host) { |
michael@0 | 50 | this.host = host; |
michael@0 | 51 | |
michael@0 | 52 | // Clock offset in milliseconds between our client's clock and the date |
michael@0 | 53 | // reported in responses from our host. |
michael@0 | 54 | this._localtimeOffsetMsec = 0; |
michael@0 | 55 | } |
michael@0 | 56 | |
michael@0 | 57 | this.HawkClient.prototype = { |
michael@0 | 58 | |
michael@0 | 59 | /* |
michael@0 | 60 | * Construct an error message for a response. Private. |
michael@0 | 61 | * |
michael@0 | 62 | * @param restResponse |
michael@0 | 63 | * A RESTResponse object from a RESTRequest |
michael@0 | 64 | * |
michael@0 | 65 | * @param errorString |
michael@0 | 66 | * A string describing the error |
michael@0 | 67 | */ |
michael@0 | 68 | _constructError: function(restResponse, errorString) { |
michael@0 | 69 | let errorObj = { |
michael@0 | 70 | error: errorString, |
michael@0 | 71 | message: restResponse.statusText, |
michael@0 | 72 | code: restResponse.status, |
michael@0 | 73 | errno: restResponse.status |
michael@0 | 74 | }; |
michael@0 | 75 | let retryAfter = restResponse.headers && restResponse.headers["retry-after"]; |
michael@0 | 76 | retryAfter = retryAfter ? parseInt(retryAfter) : retryAfter; |
michael@0 | 77 | if (retryAfter) { |
michael@0 | 78 | errorObj.retryAfter = retryAfter; |
michael@0 | 79 | // and notify observers of the retry interval |
michael@0 | 80 | if (this.observerPrefix) { |
michael@0 | 81 | Observers.notify(this.observerPrefix + ":backoff:interval", retryAfter); |
michael@0 | 82 | } |
michael@0 | 83 | } |
michael@0 | 84 | return errorObj; |
michael@0 | 85 | }, |
michael@0 | 86 | |
michael@0 | 87 | /* |
michael@0 | 88 | * |
michael@0 | 89 | * Update clock offset by determining difference from date gives in the (RFC |
michael@0 | 90 | * 1123) Date header of a server response. Because HAWK tolerates a window |
michael@0 | 91 | * of one minute of clock skew (so two minutes total since the skew can be |
michael@0 | 92 | * positive or negative), the simple method of calculating offset here is |
michael@0 | 93 | * probably good enough. We keep the value in milliseconds to make life |
michael@0 | 94 | * easier, even though the value will not have millisecond accuracy. |
michael@0 | 95 | * |
michael@0 | 96 | * @param dateString |
michael@0 | 97 | * An RFC 1123 date string (e.g., "Mon, 13 Jan 2014 21:45:06 GMT") |
michael@0 | 98 | * |
michael@0 | 99 | * For HAWK clock skew and replay protection, see |
michael@0 | 100 | * https://github.com/hueniverse/hawk#replay-protection |
michael@0 | 101 | */ |
michael@0 | 102 | _updateClockOffset: function(dateString) { |
michael@0 | 103 | try { |
michael@0 | 104 | let serverDateMsec = Date.parse(dateString); |
michael@0 | 105 | this._localtimeOffsetMsec = serverDateMsec - this.now(); |
michael@0 | 106 | log.debug("Clock offset vs " + this.host + ": " + this._localtimeOffsetMsec); |
michael@0 | 107 | } catch(err) { |
michael@0 | 108 | log.warn("Bad date header in server response: " + dateString); |
michael@0 | 109 | } |
michael@0 | 110 | }, |
michael@0 | 111 | |
michael@0 | 112 | /* |
michael@0 | 113 | * Get the current clock offset in milliseconds. |
michael@0 | 114 | * |
michael@0 | 115 | * The offset is the number of milliseconds that must be added to the client |
michael@0 | 116 | * clock to make it equal to the server clock. For example, if the client is |
michael@0 | 117 | * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. |
michael@0 | 118 | */ |
michael@0 | 119 | get localtimeOffsetMsec() { |
michael@0 | 120 | return this._localtimeOffsetMsec; |
michael@0 | 121 | }, |
michael@0 | 122 | |
michael@0 | 123 | /* |
michael@0 | 124 | * return current time in milliseconds |
michael@0 | 125 | */ |
michael@0 | 126 | now: function() { |
michael@0 | 127 | return Date.now(); |
michael@0 | 128 | }, |
michael@0 | 129 | |
michael@0 | 130 | /* A general method for sending raw RESTRequest calls authorized using HAWK |
michael@0 | 131 | * |
michael@0 | 132 | * @param path |
michael@0 | 133 | * API endpoint path |
michael@0 | 134 | * @param method |
michael@0 | 135 | * The HTTP request method |
michael@0 | 136 | * @param credentials |
michael@0 | 137 | * Hawk credentials |
michael@0 | 138 | * @param payloadObj |
michael@0 | 139 | * An object that can be encodable as JSON as the payload of the |
michael@0 | 140 | * request |
michael@0 | 141 | * @return Promise |
michael@0 | 142 | * Returns a promise that resolves to the text response of the API call, |
michael@0 | 143 | * or is rejected with an error. If the server response can be parsed |
michael@0 | 144 | * as JSON and contains an 'error' property, the promise will be |
michael@0 | 145 | * rejected with this JSON-parsed response. |
michael@0 | 146 | */ |
michael@0 | 147 | request: function(path, method, credentials=null, payloadObj={}, retryOK=true) { |
michael@0 | 148 | method = method.toLowerCase(); |
michael@0 | 149 | |
michael@0 | 150 | let deferred = Promise.defer(); |
michael@0 | 151 | let uri = this.host + path; |
michael@0 | 152 | let self = this; |
michael@0 | 153 | |
michael@0 | 154 | function _onComplete(error) { |
michael@0 | 155 | let restResponse = this.response; |
michael@0 | 156 | let status = restResponse.status; |
michael@0 | 157 | |
michael@0 | 158 | log.debug("(Response) " + path + ": code: " + status + |
michael@0 | 159 | " - Status text: " + restResponse.statusText); |
michael@0 | 160 | if (logPII) { |
michael@0 | 161 | log.debug("Response text: " + restResponse.body); |
michael@0 | 162 | } |
michael@0 | 163 | |
michael@0 | 164 | // All responses may have backoff headers, which are a server-side safety |
michael@0 | 165 | // valve to allow slowing down clients without hurting performance. |
michael@0 | 166 | self._maybeNotifyBackoff(restResponse, "x-weave-backoff"); |
michael@0 | 167 | self._maybeNotifyBackoff(restResponse, "x-backoff"); |
michael@0 | 168 | |
michael@0 | 169 | if (error) { |
michael@0 | 170 | // When things really blow up, reconstruct an error object that follows |
michael@0 | 171 | // the general format of the server on error responses. |
michael@0 | 172 | return deferred.reject(self._constructError(restResponse, error)); |
michael@0 | 173 | } |
michael@0 | 174 | |
michael@0 | 175 | self._updateClockOffset(restResponse.headers["date"]); |
michael@0 | 176 | |
michael@0 | 177 | if (status === 401 && retryOK && !("retry-after" in restResponse.headers)) { |
michael@0 | 178 | // Retry once if we were rejected due to a bad timestamp. |
michael@0 | 179 | // Clock offset is adjusted already in the top of this function. |
michael@0 | 180 | log.debug("Received 401 for " + path + ": retrying"); |
michael@0 | 181 | return deferred.resolve( |
michael@0 | 182 | self.request(path, method, credentials, payloadObj, false)); |
michael@0 | 183 | } |
michael@0 | 184 | |
michael@0 | 185 | // If the server returned a json error message, use it in the rejection |
michael@0 | 186 | // of the promise. |
michael@0 | 187 | // |
michael@0 | 188 | // In the case of a 401, in which we are probably being rejected for a |
michael@0 | 189 | // bad timestamp, retry exactly once, during which time clock offset will |
michael@0 | 190 | // be adjusted. |
michael@0 | 191 | |
michael@0 | 192 | let jsonResponse = {}; |
michael@0 | 193 | try { |
michael@0 | 194 | jsonResponse = JSON.parse(restResponse.body); |
michael@0 | 195 | } catch(notJSON) {} |
michael@0 | 196 | |
michael@0 | 197 | let okResponse = (200 <= status && status < 300); |
michael@0 | 198 | if (!okResponse || jsonResponse.error) { |
michael@0 | 199 | if (jsonResponse.error) { |
michael@0 | 200 | return deferred.reject(jsonResponse); |
michael@0 | 201 | } |
michael@0 | 202 | return deferred.reject(self._constructError(restResponse, "Request failed")); |
michael@0 | 203 | } |
michael@0 | 204 | // It's up to the caller to know how to decode the response. |
michael@0 | 205 | // We just return the raw text. |
michael@0 | 206 | deferred.resolve(this.response.body); |
michael@0 | 207 | }; |
michael@0 | 208 | |
michael@0 | 209 | function onComplete(error) { |
michael@0 | 210 | try { |
michael@0 | 211 | // |this| is the RESTRequest object and we need to ensure _onComplete |
michael@0 | 212 | // gets the same one. |
michael@0 | 213 | _onComplete.call(this, error); |
michael@0 | 214 | } catch (ex) { |
michael@0 | 215 | log.error("Unhandled exception processing response:" + |
michael@0 | 216 | CommonUtils.exceptionStr(ex)); |
michael@0 | 217 | deferred.reject(ex); |
michael@0 | 218 | } |
michael@0 | 219 | } |
michael@0 | 220 | |
michael@0 | 221 | let extra = { |
michael@0 | 222 | now: this.now(), |
michael@0 | 223 | localtimeOffsetMsec: this.localtimeOffsetMsec, |
michael@0 | 224 | }; |
michael@0 | 225 | |
michael@0 | 226 | let request = this.newHAWKAuthenticatedRESTRequest(uri, credentials, extra); |
michael@0 | 227 | if (method == "post" || method == "put") { |
michael@0 | 228 | request[method](payloadObj, onComplete); |
michael@0 | 229 | } else { |
michael@0 | 230 | request[method](onComplete); |
michael@0 | 231 | } |
michael@0 | 232 | |
michael@0 | 233 | return deferred.promise; |
michael@0 | 234 | }, |
michael@0 | 235 | |
michael@0 | 236 | /* |
michael@0 | 237 | * The prefix used for all notifications sent by this module. This |
michael@0 | 238 | * allows the handler of notifications to be sure they are handling |
michael@0 | 239 | * notifications for the service they expect. |
michael@0 | 240 | * |
michael@0 | 241 | * If not set, no notifications will be sent. |
michael@0 | 242 | */ |
michael@0 | 243 | observerPrefix: null, |
michael@0 | 244 | |
michael@0 | 245 | // Given an optional header value, notify that a backoff has been requested. |
michael@0 | 246 | _maybeNotifyBackoff: function (response, headerName) { |
michael@0 | 247 | if (!this.observerPrefix || !response.headers) { |
michael@0 | 248 | return; |
michael@0 | 249 | } |
michael@0 | 250 | let headerVal = response.headers[headerName]; |
michael@0 | 251 | if (!headerVal) { |
michael@0 | 252 | return; |
michael@0 | 253 | } |
michael@0 | 254 | let backoffInterval; |
michael@0 | 255 | try { |
michael@0 | 256 | backoffInterval = parseInt(headerVal, 10); |
michael@0 | 257 | } catch (ex) { |
michael@0 | 258 | log.error("hawkclient response had invalid backoff value in '" + |
michael@0 | 259 | headerName + "' header: " + headerVal); |
michael@0 | 260 | return; |
michael@0 | 261 | } |
michael@0 | 262 | Observers.notify(this.observerPrefix + ":backoff:interval", backoffInterval); |
michael@0 | 263 | }, |
michael@0 | 264 | |
michael@0 | 265 | // override points for testing. |
michael@0 | 266 | newHAWKAuthenticatedRESTRequest: function(uri, credentials, extra) { |
michael@0 | 267 | return new HAWKAuthenticatedRESTRequest(uri, credentials, extra); |
michael@0 | 268 | }, |
michael@0 | 269 | } |