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