Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
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/. */
5 "use strict";
7 this.EXPORTED_SYMBOLS = [
8 "TokenServerClient",
9 "TokenServerClientError",
10 "TokenServerClientNetworkError",
11 "TokenServerClientServerError",
12 ];
14 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
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");
22 const Prefs = new Preferences("services.common.tokenserverclient.");
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 }
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 }
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;
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 };
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,
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 }
239 if (!assertion) {
240 throw new TokenServerClientError("assertion argument is not valid.");
241 }
243 if (!cb) {
244 throw new TokenServerClientError("cb argument is not valid.");
245 }
247 this._log.debug("Beginning BID assertion exchange: " + url);
249 let req = this.newRESTRequest(url);
250 req.setHeader("Accept", "application/json");
251 req.setHeader("Authorization", "BrowserID " + assertion);
253 for (let header in addHeaders) {
254 req.setHeader(header, addHeaders[header]);
255 }
257 let client = this;
258 req.get(function onResponse(error) {
259 if (error) {
260 cb(new TokenServerClientNetworkError(error), null);
261 return;
262 }
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 }
271 try {
272 cb(error, result);
273 } catch (ex) {
274 self._log.warn("Exception when calling user-supplied callback: " +
275 CommonUtils.exceptionStr(ex));
276 }
278 cb = null;
279 }
281 try {
282 client._processTokenResponse(this.response, callCallback);
283 } catch (ex) {
284 this._log.warn("Error processing token server response: " +
285 CommonUtils.exceptionStr(ex));
287 let error = new TokenServerClientError(ex);
288 error.response = this.response;
289 callCallback(error, null);
290 }
291 });
292 },
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);
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);
313 let error = new TokenServerClientServerError("Non-JSON response.",
314 "malformed-response");
315 error.response = response;
316 cb(error, null);
317 return;
318 }
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 }
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");
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 }
349 let error = new TokenServerClientServerError();
350 error.response = response;
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 }
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);
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 }
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");
391 cb(error, null);
392 return;
393 }
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 }
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 },
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,
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 },
446 // override points for testing.
447 newRESTRequest: function(url) {
448 return new RESTRequest(url);
449 }
450 };