services/common/tokenserverclient.js

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6

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

mercurial