hawkclient.sys.mjs (11387B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /* 6 * HAWK is an HTTP authentication scheme using a message authentication code 7 * (MAC) algorithm to provide partial HTTP request cryptographic verification. 8 * 9 * For details, see: https://github.com/hueniverse/hawk 10 * 11 * With HAWK, it is essential that the clocks on clients and server not have an 12 * absolute delta of greater than one minute, as the HAWK protocol uses 13 * timestamps to reduce the possibility of replay attacks. However, it is 14 * likely that some clients' clocks will be more than a little off, especially 15 * in mobile devices, which would break HAWK-based services (like sync and 16 * firefox accounts) for those clients. 17 * 18 * This library provides a stateful HAWK client that calculates (roughly) the 19 * clock delta on the client vs the server. The library provides an interface 20 * for deriving HAWK credentials and making HAWK-authenticated REST requests to 21 * a single remote server. Therefore, callers who want to interact with 22 * multiple HAWK services should instantiate one HawkClient per service. 23 */ 24 25 import { HAWKAuthenticatedRESTRequest } from "resource://services-common/hawkrequest.sys.mjs"; 26 27 import { Observers } from "resource://services-common/observers.sys.mjs"; 28 import { Log } from "resource://gre/modules/Log.sys.mjs"; 29 30 // log.appender.dump should be one of "Fatal", "Error", "Warn", "Info", "Config", 31 // "Debug", "Trace" or "All". If none is specified, "Error" will be used by 32 // default. 33 // Note however that Sync will also add this log to *its* DumpAppender, so 34 // in a Sync context it shouldn't be necessary to adjust this - however, that 35 // also means error logs are likely to be dump'd twice but that's OK. 36 const PREF_LOG_LEVEL = "services.common.hawk.log.appender.dump"; 37 38 // A pref that can be set so "sensitive" information (eg, personally 39 // identifiable info, credentials, etc) will be logged. 40 const PREF_LOG_SENSITIVE_DETAILS = "services.common.hawk.log.sensitive"; 41 42 const lazy = {}; 43 44 ChromeUtils.defineLazyGetter(lazy, "log", function () { 45 let log = Log.repository.getLogger("Hawk"); 46 // We set the log itself to "debug" and set the level from the preference to 47 // the appender. This allows other things to send the logs to different 48 // appenders, while still allowing the pref to control what is seen via dump() 49 log.level = Log.Level.Debug; 50 let appender = new Log.DumpAppender(); 51 log.addAppender(appender); 52 appender.level = Log.Level.Error; 53 try { 54 let level = 55 Services.prefs.getPrefType(PREF_LOG_LEVEL) == 56 Ci.nsIPrefBranch.PREF_STRING && 57 Services.prefs.getStringPref(PREF_LOG_LEVEL); 58 appender.level = Log.Level[level] || Log.Level.Error; 59 } catch (e) { 60 log.error(e); 61 } 62 63 return log; 64 }); 65 66 // A boolean to indicate if personally identifiable information (or anything 67 // else sensitive, such as credentials) should be logged. 68 ChromeUtils.defineLazyGetter(lazy, "logPII", function () { 69 try { 70 return Services.prefs.getBoolPref(PREF_LOG_SENSITIVE_DETAILS); 71 } catch (_) { 72 return false; 73 } 74 }); 75 76 /** 77 * A general purpose client for making HAWK authenticated requests to a single 78 * host. Keeps track of the clock offset between the client and the host for 79 * computation of the timestamp in the HAWK Authorization header. 80 * 81 * Clients should create one HawkClient object per each server they wish to 82 * interact with. 83 * 84 * @param host 85 * The url of the host 86 */ 87 export var HawkClient = function (host) { 88 this.host = host; 89 90 // Clock offset in milliseconds between our client's clock and the date 91 // reported in responses from our host. 92 this._localtimeOffsetMsec = 0; 93 }; 94 95 HawkClient.prototype = { 96 /** 97 * Construct an error message for a response. Private. 98 * 99 * @param restResponse 100 * A RESTResponse object from a RESTRequest 101 * 102 * @param error 103 * A string or object describing the error 104 */ 105 _constructError(restResponse, error) { 106 let errorObj = { 107 error, 108 // This object is likely to be JSON.stringify'd, but neither Error() 109 // objects nor Components.Exception objects do the right thing there, 110 // so we add a new element which is simply the .toString() version of 111 // the error object, so it does appear in JSON'd values. 112 errorString: error.toString(), 113 message: restResponse.statusText, 114 code: restResponse.status, 115 errno: restResponse.status, 116 toString() { 117 return this.code + ": " + this.message; 118 }, 119 }; 120 let retryAfter = 121 restResponse.headers && restResponse.headers["retry-after"]; 122 retryAfter = retryAfter ? parseInt(retryAfter) : retryAfter; 123 if (retryAfter) { 124 errorObj.retryAfter = retryAfter; 125 // and notify observers of the retry interval 126 if (this.observerPrefix) { 127 Observers.notify(this.observerPrefix + ":backoff:interval", retryAfter); 128 } 129 } 130 return errorObj; 131 }, 132 133 /** 134 * 135 * Update clock offset by determining difference from date gives in the (RFC 136 * 1123) Date header of a server response. Because HAWK tolerates a window 137 * of one minute of clock skew (so two minutes total since the skew can be 138 * positive or negative), the simple method of calculating offset here is 139 * probably good enough. We keep the value in milliseconds to make life 140 * easier, even though the value will not have millisecond accuracy. 141 * 142 * @param dateString 143 * An RFC 1123 date string (e.g., "Mon, 13 Jan 2014 21:45:06 GMT") 144 * 145 * For HAWK clock skew and replay protection, see 146 * https://github.com/hueniverse/hawk#replay-protection 147 */ 148 _updateClockOffset(dateString) { 149 try { 150 let serverDateMsec = Date.parse(dateString); 151 this._localtimeOffsetMsec = serverDateMsec - this.now(); 152 lazy.log.debug( 153 "Clock offset vs " + this.host + ": " + this._localtimeOffsetMsec 154 ); 155 } catch (err) { 156 lazy.log.warn("Bad date header in server response: " + dateString); 157 } 158 }, 159 160 /* 161 * Get the current clock offset in milliseconds. 162 * 163 * The offset is the number of milliseconds that must be added to the client 164 * clock to make it equal to the server clock. For example, if the client is 165 * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. 166 */ 167 get localtimeOffsetMsec() { 168 return this._localtimeOffsetMsec; 169 }, 170 171 /* 172 * return current time in milliseconds 173 */ 174 now() { 175 return Date.now(); 176 }, 177 178 /** 179 * A general method for sending raw RESTRequest calls authorized using HAWK. 180 * 181 * @param path 182 * API endpoint path 183 * @param method 184 * The HTTP request method 185 * @param credentials 186 * Hawk credentials 187 * @param payloadObj 188 * An object that can be encodable as JSON as the payload of the 189 * request 190 * @param extraHeaders 191 * An object with header/value pairs to send with the request. 192 * @return Promise 193 * Returns a promise that resolves to the response of the API call, 194 * or is rejected with an error. If the server response can be parsed 195 * as JSON and contains an 'error' property, the promise will be 196 * rejected with this JSON-parsed response. 197 */ 198 async request( 199 path, 200 method, 201 credentials = null, 202 payloadObj = {}, 203 extraHeaders = {}, 204 retryOK = true 205 ) { 206 method = method.toLowerCase(); 207 208 let uri = this.host + path; 209 210 let extra = { 211 now: this.now(), 212 localtimeOffsetMsec: this.localtimeOffsetMsec, 213 headers: extraHeaders, 214 }; 215 216 let request = this.newHAWKAuthenticatedRESTRequest(uri, credentials, extra); 217 let error; 218 let restResponse = await request[method](payloadObj).catch(e => { 219 // Keep a reference to the error, log a message about it, and return the 220 // response anyway. 221 error = e; 222 lazy.log.warn("hawk request error", error); 223 return request.response; 224 }); 225 226 // This shouldn't happen anymore, but it's not exactly difficult to handle. 227 if (!restResponse) { 228 throw error; 229 } 230 231 let status = restResponse.status; 232 233 lazy.log.debug( 234 "(Response) " + 235 path + 236 ": code: " + 237 status + 238 " - Status text: " + 239 restResponse.statusText 240 ); 241 if (lazy.logPII) { 242 lazy.log.debug("Response text", restResponse.body); 243 } 244 245 // All responses may have backoff headers, which are a server-side safety 246 // valve to allow slowing down clients without hurting performance. 247 this._maybeNotifyBackoff(restResponse, "x-weave-backoff"); 248 this._maybeNotifyBackoff(restResponse, "x-backoff"); 249 250 if (error) { 251 // When things really blow up, reconstruct an error object that follows 252 // the general format of the server on error responses. 253 throw this._constructError(restResponse, error); 254 } 255 256 this._updateClockOffset(restResponse.headers.date); 257 258 if (status === 401 && retryOK && !("retry-after" in restResponse.headers)) { 259 // Retry once if we were rejected due to a bad timestamp. 260 // Clock offset is adjusted already in the top of this function. 261 lazy.log.debug("Received 401 for " + path + ": retrying"); 262 return this.request( 263 path, 264 method, 265 credentials, 266 payloadObj, 267 extraHeaders, 268 false 269 ); 270 } 271 272 // If the server returned a json error message, use it in the rejection 273 // of the promise. 274 // 275 // In the case of a 401, in which we are probably being rejected for a 276 // bad timestamp, retry exactly once, during which time clock offset will 277 // be adjusted. 278 279 let jsonResponse = {}; 280 try { 281 jsonResponse = JSON.parse(restResponse.body); 282 } catch (notJSON) {} 283 284 let okResponse = 200 <= status && status < 300; 285 if (!okResponse || jsonResponse.error) { 286 if (jsonResponse.error) { 287 throw jsonResponse; 288 } 289 throw this._constructError(restResponse, "Request failed"); 290 } 291 292 // It's up to the caller to know how to decode the response. 293 // We just return the whole response. 294 return restResponse; 295 }, 296 297 /* 298 * The prefix used for all notifications sent by this module. This 299 * allows the handler of notifications to be sure they are handling 300 * notifications for the service they expect. 301 * 302 * If not set, no notifications will be sent. 303 */ 304 observerPrefix: null, 305 306 // Given an optional header value, notify that a backoff has been requested. 307 _maybeNotifyBackoff(response, headerName) { 308 if (!this.observerPrefix || !response.headers) { 309 return; 310 } 311 let headerVal = response.headers[headerName]; 312 if (!headerVal) { 313 return; 314 } 315 let backoffInterval; 316 try { 317 backoffInterval = parseInt(headerVal, 10); 318 } catch (ex) { 319 lazy.log.error( 320 "hawkclient response had invalid backoff value in '" + 321 headerName + 322 "' header: " + 323 headerVal 324 ); 325 return; 326 } 327 Observers.notify( 328 this.observerPrefix + ":backoff:interval", 329 backoffInterval 330 ); 331 }, 332 333 // override points for testing. 334 newHAWKAuthenticatedRESTRequest(uri, credentials, extra) { 335 return new HAWKAuthenticatedRESTRequest(uri, credentials, extra); 336 }, 337 };