Bug 1842773 - Part 5: Add ArrayBuffer.prototype.{maxByteLength,resizable} getters...
[gecko.git] / services / common / hawkclient.sys.mjs
blobcfae8eb0f1da0010cc6a417b739592b4e70bd7f8
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";
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";
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";
42 const lazy = {};
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.getCharPref(PREF_LOG_LEVEL);
58     appender.level = Log.Level[level] || Log.Level.Error;
59   } catch (e) {
60     log.error(e);
61   }
63   return log;
64 });
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 });
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;
90   // Clock offset in milliseconds between our client's clock and the date
91   // reported in responses from our host.
92   this._localtimeOffsetMsec = 0;
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   },
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   },
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   },
171   /*
172    * return current time in milliseconds
173    */
174   now() {
175     return Date.now();
176   },
178   /* A general method for sending raw RESTRequest calls authorized using HAWK
179    *
180    * @param path
181    *        API endpoint path
182    * @param method
183    *        The HTTP request method
184    * @param credentials
185    *        Hawk credentials
186    * @param payloadObj
187    *        An object that can be encodable as JSON as the payload of the
188    *        request
189    * @param extraHeaders
190    *        An object with header/value pairs to send with the request.
191    * @return Promise
192    *        Returns a promise that resolves to the response of the API call,
193    *        or is rejected with an error.  If the server response can be parsed
194    *        as JSON and contains an 'error' property, the promise will be
195    *        rejected with this JSON-parsed response.
196    */
197   async request(
198     path,
199     method,
200     credentials = null,
201     payloadObj = {},
202     extraHeaders = {},
203     retryOK = true
204   ) {
205     method = method.toLowerCase();
207     let uri = this.host + path;
209     let extra = {
210       now: this.now(),
211       localtimeOffsetMsec: this.localtimeOffsetMsec,
212       headers: extraHeaders,
213     };
215     let request = this.newHAWKAuthenticatedRESTRequest(uri, credentials, extra);
216     let error;
217     let restResponse = await request[method](payloadObj).catch(e => {
218       // Keep a reference to the error, log a message about it, and return the
219       // response anyway.
220       error = e;
221       lazy.log.warn("hawk request error", error);
222       return request.response;
223     });
225     // This shouldn't happen anymore, but it's not exactly difficult to handle.
226     if (!restResponse) {
227       throw error;
228     }
230     let status = restResponse.status;
232     lazy.log.debug(
233       "(Response) " +
234         path +
235         ": code: " +
236         status +
237         " - Status text: " +
238         restResponse.statusText
239     );
240     if (lazy.logPII) {
241       lazy.log.debug("Response text", restResponse.body);
242     }
244     // All responses may have backoff headers, which are a server-side safety
245     // valve to allow slowing down clients without hurting performance.
246     this._maybeNotifyBackoff(restResponse, "x-weave-backoff");
247     this._maybeNotifyBackoff(restResponse, "x-backoff");
249     if (error) {
250       // When things really blow up, reconstruct an error object that follows
251       // the general format of the server on error responses.
252       throw this._constructError(restResponse, error);
253     }
255     this._updateClockOffset(restResponse.headers.date);
257     if (status === 401 && retryOK && !("retry-after" in restResponse.headers)) {
258       // Retry once if we were rejected due to a bad timestamp.
259       // Clock offset is adjusted already in the top of this function.
260       lazy.log.debug("Received 401 for " + path + ": retrying");
261       return this.request(
262         path,
263         method,
264         credentials,
265         payloadObj,
266         extraHeaders,
267         false
268       );
269     }
271     // If the server returned a json error message, use it in the rejection
272     // of the promise.
273     //
274     // In the case of a 401, in which we are probably being rejected for a
275     // bad timestamp, retry exactly once, during which time clock offset will
276     // be adjusted.
278     let jsonResponse = {};
279     try {
280       jsonResponse = JSON.parse(restResponse.body);
281     } catch (notJSON) {}
283     let okResponse = 200 <= status && status < 300;
284     if (!okResponse || jsonResponse.error) {
285       if (jsonResponse.error) {
286         throw jsonResponse;
287       }
288       throw this._constructError(restResponse, "Request failed");
289     }
291     // It's up to the caller to know how to decode the response.
292     // We just return the whole response.
293     return restResponse;
294   },
296   /*
297    * The prefix used for all notifications sent by this module.  This
298    * allows the handler of notifications to be sure they are handling
299    * notifications for the service they expect.
300    *
301    * If not set, no notifications will be sent.
302    */
303   observerPrefix: null,
305   // Given an optional header value, notify that a backoff has been requested.
306   _maybeNotifyBackoff(response, headerName) {
307     if (!this.observerPrefix || !response.headers) {
308       return;
309     }
310     let headerVal = response.headers[headerName];
311     if (!headerVal) {
312       return;
313     }
314     let backoffInterval;
315     try {
316       backoffInterval = parseInt(headerVal, 10);
317     } catch (ex) {
318       lazy.log.error(
319         "hawkclient response had invalid backoff value in '" +
320           headerName +
321           "' header: " +
322           headerVal
323       );
324       return;
325     }
326     Observers.notify(
327       this.observerPrefix + ":backoff:interval",
328       backoffInterval
329     );
330   },
332   // override points for testing.
333   newHAWKAuthenticatedRESTRequest(uri, credentials, extra) {
334     return new HAWKAuthenticatedRESTRequest(uri, credentials, extra);
335   },