|
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 file, |
|
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 |
|
5 "use strict"; |
|
6 |
|
7 this.EXPORTED_SYMBOLS = [ |
|
8 "TokenServerClient", |
|
9 "TokenServerClientError", |
|
10 "TokenServerClientNetworkError", |
|
11 "TokenServerClientServerError", |
|
12 ]; |
|
13 |
|
14 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; |
|
15 |
|
16 Cu.import("resource://gre/modules/Preferences.jsm"); |
|
17 Cu.import("resource://gre/modules/Log.jsm"); |
|
18 Cu.import("resource://services-common/rest.js"); |
|
19 Cu.import("resource://services-common/utils.js"); |
|
20 Cu.import("resource://services-common/observers.js"); |
|
21 |
|
22 const Prefs = new Preferences("services.common.tokenserverclient."); |
|
23 |
|
24 /** |
|
25 * Represents a TokenServerClient error that occurred on the client. |
|
26 * |
|
27 * This is the base type for all errors raised by client operations. |
|
28 * |
|
29 * @param message |
|
30 * (string) Error message. |
|
31 */ |
|
32 this.TokenServerClientError = function TokenServerClientError(message) { |
|
33 this.name = "TokenServerClientError"; |
|
34 this.message = message || "Client error."; |
|
35 } |
|
36 TokenServerClientError.prototype = new Error(); |
|
37 TokenServerClientError.prototype.constructor = TokenServerClientError; |
|
38 TokenServerClientError.prototype._toStringFields = function() { |
|
39 return {message: this.message}; |
|
40 } |
|
41 TokenServerClientError.prototype.toString = function() { |
|
42 return this.name + "(" + JSON.stringify(this._toStringFields()) + ")"; |
|
43 } |
|
44 |
|
45 /** |
|
46 * Represents a TokenServerClient error that occurred in the network layer. |
|
47 * |
|
48 * @param error |
|
49 * The underlying error thrown by the network layer. |
|
50 */ |
|
51 this.TokenServerClientNetworkError = |
|
52 function TokenServerClientNetworkError(error) { |
|
53 this.name = "TokenServerClientNetworkError"; |
|
54 this.error = error; |
|
55 } |
|
56 TokenServerClientNetworkError.prototype = new TokenServerClientError(); |
|
57 TokenServerClientNetworkError.prototype.constructor = |
|
58 TokenServerClientNetworkError; |
|
59 TokenServerClientNetworkError.prototype._toStringFields = function() { |
|
60 return {error: this.error}; |
|
61 } |
|
62 |
|
63 /** |
|
64 * Represents a TokenServerClient error that occurred on the server. |
|
65 * |
|
66 * This type will be encountered for all non-200 response codes from the |
|
67 * server. The type of error is strongly enumerated and is stored in the |
|
68 * `cause` property. This property can have the following string values: |
|
69 * |
|
70 * conditions-required -- The server is requesting that the client |
|
71 * agree to service conditions before it can obtain a token. The |
|
72 * conditions that must be presented to the user and agreed to are in |
|
73 * the `urls` mapping on the instance. Keys of this mapping are |
|
74 * identifiers. Values are string URLs. |
|
75 * |
|
76 * invalid-credentials -- A token could not be obtained because |
|
77 * the credentials presented by the client were invalid. |
|
78 * |
|
79 * unknown-service -- The requested service was not found. |
|
80 * |
|
81 * malformed-request -- The server rejected the request because it |
|
82 * was invalid. If you see this, code in this file is likely wrong. |
|
83 * |
|
84 * malformed-response -- The response from the server was not what was |
|
85 * expected. |
|
86 * |
|
87 * general -- A general server error has occurred. Clients should |
|
88 * interpret this as an opaque failure. |
|
89 * |
|
90 * @param message |
|
91 * (string) Error message. |
|
92 */ |
|
93 this.TokenServerClientServerError = |
|
94 function TokenServerClientServerError(message, cause="general") { |
|
95 this.now = new Date().toISOString(); // may be useful to diagnose time-skew issues. |
|
96 this.name = "TokenServerClientServerError"; |
|
97 this.message = message || "Server error."; |
|
98 this.cause = cause; |
|
99 } |
|
100 TokenServerClientServerError.prototype = new TokenServerClientError(); |
|
101 TokenServerClientServerError.prototype.constructor = |
|
102 TokenServerClientServerError; |
|
103 |
|
104 TokenServerClientServerError.prototype._toStringFields = function() { |
|
105 let fields = { |
|
106 now: this.now, |
|
107 message: this.message, |
|
108 cause: this.cause, |
|
109 }; |
|
110 if (this.response) { |
|
111 fields.response_body = this.response.body; |
|
112 fields.response_headers = this.response.headers; |
|
113 fields.response_status = this.response.status; |
|
114 } |
|
115 return fields; |
|
116 }; |
|
117 |
|
118 /** |
|
119 * Represents a client to the Token Server. |
|
120 * |
|
121 * http://docs.services.mozilla.com/token/index.html |
|
122 * |
|
123 * The Token Server supports obtaining tokens for arbitrary apps by |
|
124 * constructing URI paths of the form <app>/<app_version>. However, the service |
|
125 * discovery mechanism emphasizes the use of full URIs and tries to not force |
|
126 * the client to manipulate URIs. This client currently enforces this practice |
|
127 * by not implementing an API which would perform URI manipulation. |
|
128 * |
|
129 * If you are tempted to implement this API in the future, consider this your |
|
130 * warning that you may be doing it wrong and that you should store full URIs |
|
131 * instead. |
|
132 * |
|
133 * Areas to Improve: |
|
134 * |
|
135 * - The server sends a JSON response on error. The client does not currently |
|
136 * parse this. It might be convenient if it did. |
|
137 * - Currently most non-200 status codes are rolled into one error type. It |
|
138 * might be helpful if callers had a richer API that communicated who was |
|
139 * at fault (e.g. differentiating a 503 from a 401). |
|
140 */ |
|
141 this.TokenServerClient = function TokenServerClient() { |
|
142 this._log = Log.repository.getLogger("Common.TokenServerClient"); |
|
143 this._log.level = Log.Level[Prefs.get("logger.level")]; |
|
144 } |
|
145 TokenServerClient.prototype = { |
|
146 /** |
|
147 * Logger instance. |
|
148 */ |
|
149 _log: null, |
|
150 |
|
151 /** |
|
152 * Obtain a token from a BrowserID assertion against a specific URL. |
|
153 * |
|
154 * This asynchronously obtains the token. The callback receives 2 arguments: |
|
155 * |
|
156 * (TokenServerClientError | null) If no token could be obtained, this |
|
157 * will be a TokenServerClientError instance describing why. The |
|
158 * type seen defines the type of error encountered. If an HTTP response |
|
159 * was seen, a RESTResponse instance will be stored in the `response` |
|
160 * property of this object. If there was no error and a token is |
|
161 * available, this will be null. |
|
162 * |
|
163 * (map | null) On success, this will be a map containing the results from |
|
164 * the server. If there was an error, this will be null. The map has the |
|
165 * following properties: |
|
166 * |
|
167 * id (string) HTTP MAC public key identifier. |
|
168 * key (string) HTTP MAC shared symmetric key. |
|
169 * endpoint (string) URL where service can be connected to. |
|
170 * uid (string) user ID for requested service. |
|
171 * duration (string) the validity duration of the issued token. |
|
172 * |
|
173 * Terms of Service Acceptance |
|
174 * --------------------------- |
|
175 * |
|
176 * Some services require users to accept terms of service before they can |
|
177 * obtain a token. If a service requires ToS acceptance, the error passed |
|
178 * to the callback will be a `TokenServerClientServerError` with the |
|
179 * `cause` property set to "conditions-required". The `urls` property of that |
|
180 * instance will be a map of string keys to string URL values. The user-agent |
|
181 * should prompt the user to accept the content at these URLs. |
|
182 * |
|
183 * Clients signify acceptance of the terms of service by sending a token |
|
184 * request with additional metadata. This is controlled by the |
|
185 * `conditionsAccepted` argument to this function. Clients only need to set |
|
186 * this flag once per service and the server remembers acceptance. If |
|
187 * the conditions for the service change, the server may request |
|
188 * clients agree to terms again. Therefore, clients should always be |
|
189 * prepared to handle a conditions required response. |
|
190 * |
|
191 * Clients should not blindly send acceptance to conditions. Instead, clients |
|
192 * should set `conditionsAccepted` if and only if the server asks for |
|
193 * acceptance, the conditions are displayed to the user, and the user agrees |
|
194 * to them. |
|
195 * |
|
196 * Example Usage |
|
197 * ------------- |
|
198 * |
|
199 * let client = new TokenServerClient(); |
|
200 * let assertion = getBrowserIDAssertionFromSomewhere(); |
|
201 * let url = "https://token.services.mozilla.com/1.0/sync/2.0"; |
|
202 * |
|
203 * client.getTokenFromBrowserIDAssertion(url, assertion, |
|
204 * function onResponse(error, result) { |
|
205 * if (error) { |
|
206 * if (error.cause == "conditions-required") { |
|
207 * promptConditionsAcceptance(error.urls, function onAccept() { |
|
208 * client.getTokenFromBrowserIDAssertion(url, assertion, |
|
209 * onResponse, true); |
|
210 * } |
|
211 * return; |
|
212 * } |
|
213 * |
|
214 * // Do other error handling. |
|
215 * return; |
|
216 * } |
|
217 * |
|
218 * let { |
|
219 * id: id, key: key, uid: uid, endpoint: endpoint, duration: duration |
|
220 * } = result; |
|
221 * // Do stuff with data and carry on. |
|
222 * }); |
|
223 * |
|
224 * @param url |
|
225 * (string) URL to fetch token from. |
|
226 * @param assertion |
|
227 * (string) BrowserID assertion to exchange token for. |
|
228 * @param cb |
|
229 * (function) Callback to be invoked with result of operation. |
|
230 * @param conditionsAccepted |
|
231 * (bool) Whether to send acceptance to service conditions. |
|
232 */ |
|
233 getTokenFromBrowserIDAssertion: |
|
234 function getTokenFromBrowserIDAssertion(url, assertion, cb, addHeaders={}) { |
|
235 if (!url) { |
|
236 throw new TokenServerClientError("url argument is not valid."); |
|
237 } |
|
238 |
|
239 if (!assertion) { |
|
240 throw new TokenServerClientError("assertion argument is not valid."); |
|
241 } |
|
242 |
|
243 if (!cb) { |
|
244 throw new TokenServerClientError("cb argument is not valid."); |
|
245 } |
|
246 |
|
247 this._log.debug("Beginning BID assertion exchange: " + url); |
|
248 |
|
249 let req = this.newRESTRequest(url); |
|
250 req.setHeader("Accept", "application/json"); |
|
251 req.setHeader("Authorization", "BrowserID " + assertion); |
|
252 |
|
253 for (let header in addHeaders) { |
|
254 req.setHeader(header, addHeaders[header]); |
|
255 } |
|
256 |
|
257 let client = this; |
|
258 req.get(function onResponse(error) { |
|
259 if (error) { |
|
260 cb(new TokenServerClientNetworkError(error), null); |
|
261 return; |
|
262 } |
|
263 |
|
264 let self = this; |
|
265 function callCallback(error, result) { |
|
266 if (!cb) { |
|
267 self._log.warn("Callback already called! Did it throw?"); |
|
268 return; |
|
269 } |
|
270 |
|
271 try { |
|
272 cb(error, result); |
|
273 } catch (ex) { |
|
274 self._log.warn("Exception when calling user-supplied callback: " + |
|
275 CommonUtils.exceptionStr(ex)); |
|
276 } |
|
277 |
|
278 cb = null; |
|
279 } |
|
280 |
|
281 try { |
|
282 client._processTokenResponse(this.response, callCallback); |
|
283 } catch (ex) { |
|
284 this._log.warn("Error processing token server response: " + |
|
285 CommonUtils.exceptionStr(ex)); |
|
286 |
|
287 let error = new TokenServerClientError(ex); |
|
288 error.response = this.response; |
|
289 callCallback(error, null); |
|
290 } |
|
291 }); |
|
292 }, |
|
293 |
|
294 /** |
|
295 * Handler to process token request responses. |
|
296 * |
|
297 * @param response |
|
298 * RESTResponse from token HTTP request. |
|
299 * @param cb |
|
300 * The original callback passed to the public API. |
|
301 */ |
|
302 _processTokenResponse: function processTokenResponse(response, cb) { |
|
303 this._log.debug("Got token response: " + response.status); |
|
304 |
|
305 // Responses should *always* be JSON, even in the case of 4xx and 5xx |
|
306 // errors. If we don't see JSON, the server is likely very unhappy. |
|
307 let ct = response.headers["content-type"] || ""; |
|
308 if (ct != "application/json" && !ct.startsWith("application/json;")) { |
|
309 this._log.warn("Did not receive JSON response. Misconfigured server?"); |
|
310 this._log.debug("Content-Type: " + ct); |
|
311 this._log.debug("Body: " + response.body); |
|
312 |
|
313 let error = new TokenServerClientServerError("Non-JSON response.", |
|
314 "malformed-response"); |
|
315 error.response = response; |
|
316 cb(error, null); |
|
317 return; |
|
318 } |
|
319 |
|
320 let result; |
|
321 try { |
|
322 result = JSON.parse(response.body); |
|
323 } catch (ex) { |
|
324 this._log.warn("Invalid JSON returned by server: " + response.body); |
|
325 let error = new TokenServerClientServerError("Malformed JSON.", |
|
326 "malformed-response"); |
|
327 error.response = response; |
|
328 cb(error, null); |
|
329 return; |
|
330 } |
|
331 |
|
332 // Any response status can have X-Backoff or X-Weave-Backoff headers. |
|
333 this._maybeNotifyBackoff(response, "x-weave-backoff"); |
|
334 this._maybeNotifyBackoff(response, "x-backoff"); |
|
335 |
|
336 // The service shouldn't have any 3xx, so we don't need to handle those. |
|
337 if (response.status != 200) { |
|
338 // We /should/ have a Cornice error report in the JSON. We log that to |
|
339 // help with debugging. |
|
340 if ("errors" in result) { |
|
341 // This could throw, but this entire function is wrapped in a try. If |
|
342 // the server is sending something not an array of objects, it has |
|
343 // failed to keep its contract with us and there is little we can do. |
|
344 for (let error of result.errors) { |
|
345 this._log.info("Server-reported error: " + JSON.stringify(error)); |
|
346 } |
|
347 } |
|
348 |
|
349 let error = new TokenServerClientServerError(); |
|
350 error.response = response; |
|
351 |
|
352 if (response.status == 400) { |
|
353 error.message = "Malformed request."; |
|
354 error.cause = "malformed-request"; |
|
355 } else if (response.status == 401) { |
|
356 // Cause can be invalid-credentials, invalid-timestamp, or |
|
357 // invalid-generation. |
|
358 error.message = "Authentication failed."; |
|
359 error.cause = result.status; |
|
360 } |
|
361 |
|
362 // 403 should represent a "condition acceptance needed" response. |
|
363 // |
|
364 // The extra validation of "urls" is important. We don't want to signal |
|
365 // conditions required unless we are absolutely sure that is what the |
|
366 // server is asking for. |
|
367 else if (response.status == 403) { |
|
368 if (!("urls" in result)) { |
|
369 this._log.warn("403 response without proper fields!"); |
|
370 this._log.warn("Response body: " + response.body); |
|
371 |
|
372 error.message = "Missing JSON fields."; |
|
373 error.cause = "malformed-response"; |
|
374 } else if (typeof(result.urls) != "object") { |
|
375 error.message = "urls field is not a map."; |
|
376 error.cause = "malformed-response"; |
|
377 } else { |
|
378 error.message = "Conditions must be accepted."; |
|
379 error.cause = "conditions-required"; |
|
380 error.urls = result.urls; |
|
381 } |
|
382 } else if (response.status == 404) { |
|
383 error.message = "Unknown service."; |
|
384 error.cause = "unknown-service"; |
|
385 } |
|
386 |
|
387 // A Retry-After header should theoretically only appear on a 503, but |
|
388 // we'll look for it on any error response. |
|
389 this._maybeNotifyBackoff(response, "retry-after"); |
|
390 |
|
391 cb(error, null); |
|
392 return; |
|
393 } |
|
394 |
|
395 for (let k of ["id", "key", "api_endpoint", "uid", "duration"]) { |
|
396 if (!(k in result)) { |
|
397 let error = new TokenServerClientServerError("Expected key not " + |
|
398 " present in result: " + |
|
399 k); |
|
400 error.cause = "malformed-response"; |
|
401 error.response = response; |
|
402 cb(error, null); |
|
403 return; |
|
404 } |
|
405 } |
|
406 |
|
407 this._log.debug("Successful token response: " + result.id); |
|
408 cb(null, { |
|
409 id: result.id, |
|
410 key: result.key, |
|
411 endpoint: result.api_endpoint, |
|
412 uid: result.uid, |
|
413 duration: result.duration, |
|
414 }); |
|
415 }, |
|
416 |
|
417 /* |
|
418 * The prefix used for all notifications sent by this module. This |
|
419 * allows the handler of notifications to be sure they are handling |
|
420 * notifications for the service they expect. |
|
421 * |
|
422 * If not set, no notifications will be sent. |
|
423 */ |
|
424 observerPrefix: null, |
|
425 |
|
426 // Given an optional header value, notify that a backoff has been requested. |
|
427 _maybeNotifyBackoff: function (response, headerName) { |
|
428 if (!this.observerPrefix) { |
|
429 return; |
|
430 } |
|
431 let headerVal = response.headers[headerName]; |
|
432 if (!headerVal) { |
|
433 return; |
|
434 } |
|
435 let backoffInterval; |
|
436 try { |
|
437 backoffInterval = parseInt(headerVal, 10); |
|
438 } catch (ex) { |
|
439 this._log.error("TokenServer response had invalid backoff value in '" + |
|
440 headerName + "' header: " + headerVal); |
|
441 return; |
|
442 } |
|
443 Observers.notify(this.observerPrefix + ":backoff:interval", backoffInterval); |
|
444 }, |
|
445 |
|
446 // override points for testing. |
|
447 newRESTRequest: function(url) { |
|
448 return new RESTRequest(url); |
|
449 } |
|
450 }; |