Bug 1829125 - Align the PHC area to the jemalloc chunk size r=glandium
[gecko.git] / services / common / hawkclient.sys.mjs
blob4e45ad13ee0141e2a8dedd7527dfcc420b37f799
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 /*
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  */
25 import { HAWKAuthenticatedRESTRequest } from "resource://services-common/hawkrequest.sys.mjs";
27 import { Observers } from "resource://services-common/observers.sys.mjs";
28 import { Log } from "resource://gre/modules/Log.sys.mjs";
29 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
31 // log.appender.dump should be one of "Fatal", "Error", "Warn", "Info", "Config",
32 // "Debug", "Trace" or "All". If none is specified, "Error" will be used by
33 // default.
34 // Note however that Sync will also add this log to *its* DumpAppender, so
35 // in a Sync context it shouldn't be necessary to adjust this - however, that
36 // also means error logs are likely to be dump'd twice but that's OK.
37 const PREF_LOG_LEVEL = "services.common.hawk.log.appender.dump";
39 // A pref that can be set so "sensitive" information (eg, personally
40 // identifiable info, credentials, etc) will be logged.
41 const PREF_LOG_SENSITIVE_DETAILS = "services.common.hawk.log.sensitive";
43 const lazy = {};
45 XPCOMUtils.defineLazyGetter(lazy, "log", function () {
46   let log = Log.repository.getLogger("Hawk");
47   // We set the log itself to "debug" and set the level from the preference to
48   // the appender.  This allows other things to send the logs to different
49   // appenders, while still allowing the pref to control what is seen via dump()
50   log.level = Log.Level.Debug;
51   let appender = new Log.DumpAppender();
52   log.addAppender(appender);
53   appender.level = Log.Level.Error;
54   try {
55     let level =
56       Services.prefs.getPrefType(PREF_LOG_LEVEL) ==
57         Ci.nsIPrefBranch.PREF_STRING &&
58       Services.prefs.getCharPref(PREF_LOG_LEVEL);
59     appender.level = Log.Level[level] || Log.Level.Error;
60   } catch (e) {
61     log.error(e);
62   }
64   return log;
65 });
67 // A boolean to indicate if personally identifiable information (or anything
68 // else sensitive, such as credentials) should be logged.
69 XPCOMUtils.defineLazyGetter(lazy, "logPII", function () {
70   try {
71     return Services.prefs.getBoolPref(PREF_LOG_SENSITIVE_DETAILS);
72   } catch (_) {
73     return false;
74   }
75 });
78  * A general purpose client for making HAWK authenticated requests to a single
79  * host.  Keeps track of the clock offset between the client and the host for
80  * computation of the timestamp in the HAWK Authorization header.
81  *
82  * Clients should create one HawkClient object per each server they wish to
83  * interact with.
84  *
85  * @param host
86  *        The url of the host
87  */
88 export var HawkClient = function (host) {
89   this.host = host;
91   // Clock offset in milliseconds between our client's clock and the date
92   // reported in responses from our host.
93   this._localtimeOffsetMsec = 0;
96 HawkClient.prototype = {
97   /*
98    * Construct an error message for a response.  Private.
99    *
100    * @param restResponse
101    *        A RESTResponse object from a RESTRequest
102    *
103    * @param error
104    *        A string or object describing the error
105    */
106   _constructError(restResponse, error) {
107     let errorObj = {
108       error,
109       // This object is likely to be JSON.stringify'd, but neither Error()
110       // objects nor Components.Exception objects do the right thing there,
111       // so we add a new element which is simply the .toString() version of
112       // the error object, so it does appear in JSON'd values.
113       errorString: error.toString(),
114       message: restResponse.statusText,
115       code: restResponse.status,
116       errno: restResponse.status,
117       toString() {
118         return this.code + ": " + this.message;
119       },
120     };
121     let retryAfter =
122       restResponse.headers && restResponse.headers["retry-after"];
123     retryAfter = retryAfter ? parseInt(retryAfter) : retryAfter;
124     if (retryAfter) {
125       errorObj.retryAfter = retryAfter;
126       // and notify observers of the retry interval
127       if (this.observerPrefix) {
128         Observers.notify(this.observerPrefix + ":backoff:interval", retryAfter);
129       }
130     }
131     return errorObj;
132   },
134   /*
135    *
136    * Update clock offset by determining difference from date gives in the (RFC
137    * 1123) Date header of a server response.  Because HAWK tolerates a window
138    * of one minute of clock skew (so two minutes total since the skew can be
139    * positive or negative), the simple method of calculating offset here is
140    * probably good enough.  We keep the value in milliseconds to make life
141    * easier, even though the value will not have millisecond accuracy.
142    *
143    * @param dateString
144    *        An RFC 1123 date string (e.g., "Mon, 13 Jan 2014 21:45:06 GMT")
145    *
146    * For HAWK clock skew and replay protection, see
147    * https://github.com/hueniverse/hawk#replay-protection
148    */
149   _updateClockOffset(dateString) {
150     try {
151       let serverDateMsec = Date.parse(dateString);
152       this._localtimeOffsetMsec = serverDateMsec - this.now();
153       lazy.log.debug(
154         "Clock offset vs " + this.host + ": " + this._localtimeOffsetMsec
155       );
156     } catch (err) {
157       lazy.log.warn("Bad date header in server response: " + dateString);
158     }
159   },
161   /*
162    * Get the current clock offset in milliseconds.
163    *
164    * The offset is the number of milliseconds that must be added to the client
165    * clock to make it equal to the server clock.  For example, if the client is
166    * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
167    */
168   get localtimeOffsetMsec() {
169     return this._localtimeOffsetMsec;
170   },
172   /*
173    * return current time in milliseconds
174    */
175   now() {
176     return Date.now();
177   },
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();
208     let uri = this.host + path;
210     let extra = {
211       now: this.now(),
212       localtimeOffsetMsec: this.localtimeOffsetMsec,
213       headers: extraHeaders,
214     };
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     });
226     // This shouldn't happen anymore, but it's not exactly difficult to handle.
227     if (!restResponse) {
228       throw error;
229     }
231     let status = restResponse.status;
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     }
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");
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     }
256     this._updateClockOffset(restResponse.headers.date);
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     }
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.
279     let jsonResponse = {};
280     try {
281       jsonResponse = JSON.parse(restResponse.body);
282     } catch (notJSON) {}
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     }
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   },
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,
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   },
333   // override points for testing.
334   newHAWKAuthenticatedRESTRequest(uri, credentials, extra) {
335     return new HAWKAuthenticatedRESTRequest(uri, credentials, extra);
336   },