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