1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/services/common/tokenserverclient.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,450 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this file, 1.6 + * You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +"use strict"; 1.9 + 1.10 +this.EXPORTED_SYMBOLS = [ 1.11 + "TokenServerClient", 1.12 + "TokenServerClientError", 1.13 + "TokenServerClientNetworkError", 1.14 + "TokenServerClientServerError", 1.15 +]; 1.16 + 1.17 +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; 1.18 + 1.19 +Cu.import("resource://gre/modules/Preferences.jsm"); 1.20 +Cu.import("resource://gre/modules/Log.jsm"); 1.21 +Cu.import("resource://services-common/rest.js"); 1.22 +Cu.import("resource://services-common/utils.js"); 1.23 +Cu.import("resource://services-common/observers.js"); 1.24 + 1.25 +const Prefs = new Preferences("services.common.tokenserverclient."); 1.26 + 1.27 +/** 1.28 + * Represents a TokenServerClient error that occurred on the client. 1.29 + * 1.30 + * This is the base type for all errors raised by client operations. 1.31 + * 1.32 + * @param message 1.33 + * (string) Error message. 1.34 + */ 1.35 +this.TokenServerClientError = function TokenServerClientError(message) { 1.36 + this.name = "TokenServerClientError"; 1.37 + this.message = message || "Client error."; 1.38 +} 1.39 +TokenServerClientError.prototype = new Error(); 1.40 +TokenServerClientError.prototype.constructor = TokenServerClientError; 1.41 +TokenServerClientError.prototype._toStringFields = function() { 1.42 + return {message: this.message}; 1.43 +} 1.44 +TokenServerClientError.prototype.toString = function() { 1.45 + return this.name + "(" + JSON.stringify(this._toStringFields()) + ")"; 1.46 +} 1.47 + 1.48 +/** 1.49 + * Represents a TokenServerClient error that occurred in the network layer. 1.50 + * 1.51 + * @param error 1.52 + * The underlying error thrown by the network layer. 1.53 + */ 1.54 +this.TokenServerClientNetworkError = 1.55 + function TokenServerClientNetworkError(error) { 1.56 + this.name = "TokenServerClientNetworkError"; 1.57 + this.error = error; 1.58 +} 1.59 +TokenServerClientNetworkError.prototype = new TokenServerClientError(); 1.60 +TokenServerClientNetworkError.prototype.constructor = 1.61 + TokenServerClientNetworkError; 1.62 +TokenServerClientNetworkError.prototype._toStringFields = function() { 1.63 + return {error: this.error}; 1.64 +} 1.65 + 1.66 +/** 1.67 + * Represents a TokenServerClient error that occurred on the server. 1.68 + * 1.69 + * This type will be encountered for all non-200 response codes from the 1.70 + * server. The type of error is strongly enumerated and is stored in the 1.71 + * `cause` property. This property can have the following string values: 1.72 + * 1.73 + * conditions-required -- The server is requesting that the client 1.74 + * agree to service conditions before it can obtain a token. The 1.75 + * conditions that must be presented to the user and agreed to are in 1.76 + * the `urls` mapping on the instance. Keys of this mapping are 1.77 + * identifiers. Values are string URLs. 1.78 + * 1.79 + * invalid-credentials -- A token could not be obtained because 1.80 + * the credentials presented by the client were invalid. 1.81 + * 1.82 + * unknown-service -- The requested service was not found. 1.83 + * 1.84 + * malformed-request -- The server rejected the request because it 1.85 + * was invalid. If you see this, code in this file is likely wrong. 1.86 + * 1.87 + * malformed-response -- The response from the server was not what was 1.88 + * expected. 1.89 + * 1.90 + * general -- A general server error has occurred. Clients should 1.91 + * interpret this as an opaque failure. 1.92 + * 1.93 + * @param message 1.94 + * (string) Error message. 1.95 + */ 1.96 +this.TokenServerClientServerError = 1.97 + function TokenServerClientServerError(message, cause="general") { 1.98 + this.now = new Date().toISOString(); // may be useful to diagnose time-skew issues. 1.99 + this.name = "TokenServerClientServerError"; 1.100 + this.message = message || "Server error."; 1.101 + this.cause = cause; 1.102 +} 1.103 +TokenServerClientServerError.prototype = new TokenServerClientError(); 1.104 +TokenServerClientServerError.prototype.constructor = 1.105 + TokenServerClientServerError; 1.106 + 1.107 +TokenServerClientServerError.prototype._toStringFields = function() { 1.108 + let fields = { 1.109 + now: this.now, 1.110 + message: this.message, 1.111 + cause: this.cause, 1.112 + }; 1.113 + if (this.response) { 1.114 + fields.response_body = this.response.body; 1.115 + fields.response_headers = this.response.headers; 1.116 + fields.response_status = this.response.status; 1.117 + } 1.118 + return fields; 1.119 +}; 1.120 + 1.121 +/** 1.122 + * Represents a client to the Token Server. 1.123 + * 1.124 + * http://docs.services.mozilla.com/token/index.html 1.125 + * 1.126 + * The Token Server supports obtaining tokens for arbitrary apps by 1.127 + * constructing URI paths of the form <app>/<app_version>. However, the service 1.128 + * discovery mechanism emphasizes the use of full URIs and tries to not force 1.129 + * the client to manipulate URIs. This client currently enforces this practice 1.130 + * by not implementing an API which would perform URI manipulation. 1.131 + * 1.132 + * If you are tempted to implement this API in the future, consider this your 1.133 + * warning that you may be doing it wrong and that you should store full URIs 1.134 + * instead. 1.135 + * 1.136 + * Areas to Improve: 1.137 + * 1.138 + * - The server sends a JSON response on error. The client does not currently 1.139 + * parse this. It might be convenient if it did. 1.140 + * - Currently most non-200 status codes are rolled into one error type. It 1.141 + * might be helpful if callers had a richer API that communicated who was 1.142 + * at fault (e.g. differentiating a 503 from a 401). 1.143 + */ 1.144 +this.TokenServerClient = function TokenServerClient() { 1.145 + this._log = Log.repository.getLogger("Common.TokenServerClient"); 1.146 + this._log.level = Log.Level[Prefs.get("logger.level")]; 1.147 +} 1.148 +TokenServerClient.prototype = { 1.149 + /** 1.150 + * Logger instance. 1.151 + */ 1.152 + _log: null, 1.153 + 1.154 + /** 1.155 + * Obtain a token from a BrowserID assertion against a specific URL. 1.156 + * 1.157 + * This asynchronously obtains the token. The callback receives 2 arguments: 1.158 + * 1.159 + * (TokenServerClientError | null) If no token could be obtained, this 1.160 + * will be a TokenServerClientError instance describing why. The 1.161 + * type seen defines the type of error encountered. If an HTTP response 1.162 + * was seen, a RESTResponse instance will be stored in the `response` 1.163 + * property of this object. If there was no error and a token is 1.164 + * available, this will be null. 1.165 + * 1.166 + * (map | null) On success, this will be a map containing the results from 1.167 + * the server. If there was an error, this will be null. The map has the 1.168 + * following properties: 1.169 + * 1.170 + * id (string) HTTP MAC public key identifier. 1.171 + * key (string) HTTP MAC shared symmetric key. 1.172 + * endpoint (string) URL where service can be connected to. 1.173 + * uid (string) user ID for requested service. 1.174 + * duration (string) the validity duration of the issued token. 1.175 + * 1.176 + * Terms of Service Acceptance 1.177 + * --------------------------- 1.178 + * 1.179 + * Some services require users to accept terms of service before they can 1.180 + * obtain a token. If a service requires ToS acceptance, the error passed 1.181 + * to the callback will be a `TokenServerClientServerError` with the 1.182 + * `cause` property set to "conditions-required". The `urls` property of that 1.183 + * instance will be a map of string keys to string URL values. The user-agent 1.184 + * should prompt the user to accept the content at these URLs. 1.185 + * 1.186 + * Clients signify acceptance of the terms of service by sending a token 1.187 + * request with additional metadata. This is controlled by the 1.188 + * `conditionsAccepted` argument to this function. Clients only need to set 1.189 + * this flag once per service and the server remembers acceptance. If 1.190 + * the conditions for the service change, the server may request 1.191 + * clients agree to terms again. Therefore, clients should always be 1.192 + * prepared to handle a conditions required response. 1.193 + * 1.194 + * Clients should not blindly send acceptance to conditions. Instead, clients 1.195 + * should set `conditionsAccepted` if and only if the server asks for 1.196 + * acceptance, the conditions are displayed to the user, and the user agrees 1.197 + * to them. 1.198 + * 1.199 + * Example Usage 1.200 + * ------------- 1.201 + * 1.202 + * let client = new TokenServerClient(); 1.203 + * let assertion = getBrowserIDAssertionFromSomewhere(); 1.204 + * let url = "https://token.services.mozilla.com/1.0/sync/2.0"; 1.205 + * 1.206 + * client.getTokenFromBrowserIDAssertion(url, assertion, 1.207 + * function onResponse(error, result) { 1.208 + * if (error) { 1.209 + * if (error.cause == "conditions-required") { 1.210 + * promptConditionsAcceptance(error.urls, function onAccept() { 1.211 + * client.getTokenFromBrowserIDAssertion(url, assertion, 1.212 + * onResponse, true); 1.213 + * } 1.214 + * return; 1.215 + * } 1.216 + * 1.217 + * // Do other error handling. 1.218 + * return; 1.219 + * } 1.220 + * 1.221 + * let { 1.222 + * id: id, key: key, uid: uid, endpoint: endpoint, duration: duration 1.223 + * } = result; 1.224 + * // Do stuff with data and carry on. 1.225 + * }); 1.226 + * 1.227 + * @param url 1.228 + * (string) URL to fetch token from. 1.229 + * @param assertion 1.230 + * (string) BrowserID assertion to exchange token for. 1.231 + * @param cb 1.232 + * (function) Callback to be invoked with result of operation. 1.233 + * @param conditionsAccepted 1.234 + * (bool) Whether to send acceptance to service conditions. 1.235 + */ 1.236 + getTokenFromBrowserIDAssertion: 1.237 + function getTokenFromBrowserIDAssertion(url, assertion, cb, addHeaders={}) { 1.238 + if (!url) { 1.239 + throw new TokenServerClientError("url argument is not valid."); 1.240 + } 1.241 + 1.242 + if (!assertion) { 1.243 + throw new TokenServerClientError("assertion argument is not valid."); 1.244 + } 1.245 + 1.246 + if (!cb) { 1.247 + throw new TokenServerClientError("cb argument is not valid."); 1.248 + } 1.249 + 1.250 + this._log.debug("Beginning BID assertion exchange: " + url); 1.251 + 1.252 + let req = this.newRESTRequest(url); 1.253 + req.setHeader("Accept", "application/json"); 1.254 + req.setHeader("Authorization", "BrowserID " + assertion); 1.255 + 1.256 + for (let header in addHeaders) { 1.257 + req.setHeader(header, addHeaders[header]); 1.258 + } 1.259 + 1.260 + let client = this; 1.261 + req.get(function onResponse(error) { 1.262 + if (error) { 1.263 + cb(new TokenServerClientNetworkError(error), null); 1.264 + return; 1.265 + } 1.266 + 1.267 + let self = this; 1.268 + function callCallback(error, result) { 1.269 + if (!cb) { 1.270 + self._log.warn("Callback already called! Did it throw?"); 1.271 + return; 1.272 + } 1.273 + 1.274 + try { 1.275 + cb(error, result); 1.276 + } catch (ex) { 1.277 + self._log.warn("Exception when calling user-supplied callback: " + 1.278 + CommonUtils.exceptionStr(ex)); 1.279 + } 1.280 + 1.281 + cb = null; 1.282 + } 1.283 + 1.284 + try { 1.285 + client._processTokenResponse(this.response, callCallback); 1.286 + } catch (ex) { 1.287 + this._log.warn("Error processing token server response: " + 1.288 + CommonUtils.exceptionStr(ex)); 1.289 + 1.290 + let error = new TokenServerClientError(ex); 1.291 + error.response = this.response; 1.292 + callCallback(error, null); 1.293 + } 1.294 + }); 1.295 + }, 1.296 + 1.297 + /** 1.298 + * Handler to process token request responses. 1.299 + * 1.300 + * @param response 1.301 + * RESTResponse from token HTTP request. 1.302 + * @param cb 1.303 + * The original callback passed to the public API. 1.304 + */ 1.305 + _processTokenResponse: function processTokenResponse(response, cb) { 1.306 + this._log.debug("Got token response: " + response.status); 1.307 + 1.308 + // Responses should *always* be JSON, even in the case of 4xx and 5xx 1.309 + // errors. If we don't see JSON, the server is likely very unhappy. 1.310 + let ct = response.headers["content-type"] || ""; 1.311 + if (ct != "application/json" && !ct.startsWith("application/json;")) { 1.312 + this._log.warn("Did not receive JSON response. Misconfigured server?"); 1.313 + this._log.debug("Content-Type: " + ct); 1.314 + this._log.debug("Body: " + response.body); 1.315 + 1.316 + let error = new TokenServerClientServerError("Non-JSON response.", 1.317 + "malformed-response"); 1.318 + error.response = response; 1.319 + cb(error, null); 1.320 + return; 1.321 + } 1.322 + 1.323 + let result; 1.324 + try { 1.325 + result = JSON.parse(response.body); 1.326 + } catch (ex) { 1.327 + this._log.warn("Invalid JSON returned by server: " + response.body); 1.328 + let error = new TokenServerClientServerError("Malformed JSON.", 1.329 + "malformed-response"); 1.330 + error.response = response; 1.331 + cb(error, null); 1.332 + return; 1.333 + } 1.334 + 1.335 + // Any response status can have X-Backoff or X-Weave-Backoff headers. 1.336 + this._maybeNotifyBackoff(response, "x-weave-backoff"); 1.337 + this._maybeNotifyBackoff(response, "x-backoff"); 1.338 + 1.339 + // The service shouldn't have any 3xx, so we don't need to handle those. 1.340 + if (response.status != 200) { 1.341 + // We /should/ have a Cornice error report in the JSON. We log that to 1.342 + // help with debugging. 1.343 + if ("errors" in result) { 1.344 + // This could throw, but this entire function is wrapped in a try. If 1.345 + // the server is sending something not an array of objects, it has 1.346 + // failed to keep its contract with us and there is little we can do. 1.347 + for (let error of result.errors) { 1.348 + this._log.info("Server-reported error: " + JSON.stringify(error)); 1.349 + } 1.350 + } 1.351 + 1.352 + let error = new TokenServerClientServerError(); 1.353 + error.response = response; 1.354 + 1.355 + if (response.status == 400) { 1.356 + error.message = "Malformed request."; 1.357 + error.cause = "malformed-request"; 1.358 + } else if (response.status == 401) { 1.359 + // Cause can be invalid-credentials, invalid-timestamp, or 1.360 + // invalid-generation. 1.361 + error.message = "Authentication failed."; 1.362 + error.cause = result.status; 1.363 + } 1.364 + 1.365 + // 403 should represent a "condition acceptance needed" response. 1.366 + // 1.367 + // The extra validation of "urls" is important. We don't want to signal 1.368 + // conditions required unless we are absolutely sure that is what the 1.369 + // server is asking for. 1.370 + else if (response.status == 403) { 1.371 + if (!("urls" in result)) { 1.372 + this._log.warn("403 response without proper fields!"); 1.373 + this._log.warn("Response body: " + response.body); 1.374 + 1.375 + error.message = "Missing JSON fields."; 1.376 + error.cause = "malformed-response"; 1.377 + } else if (typeof(result.urls) != "object") { 1.378 + error.message = "urls field is not a map."; 1.379 + error.cause = "malformed-response"; 1.380 + } else { 1.381 + error.message = "Conditions must be accepted."; 1.382 + error.cause = "conditions-required"; 1.383 + error.urls = result.urls; 1.384 + } 1.385 + } else if (response.status == 404) { 1.386 + error.message = "Unknown service."; 1.387 + error.cause = "unknown-service"; 1.388 + } 1.389 + 1.390 + // A Retry-After header should theoretically only appear on a 503, but 1.391 + // we'll look for it on any error response. 1.392 + this._maybeNotifyBackoff(response, "retry-after"); 1.393 + 1.394 + cb(error, null); 1.395 + return; 1.396 + } 1.397 + 1.398 + for (let k of ["id", "key", "api_endpoint", "uid", "duration"]) { 1.399 + if (!(k in result)) { 1.400 + let error = new TokenServerClientServerError("Expected key not " + 1.401 + " present in result: " + 1.402 + k); 1.403 + error.cause = "malformed-response"; 1.404 + error.response = response; 1.405 + cb(error, null); 1.406 + return; 1.407 + } 1.408 + } 1.409 + 1.410 + this._log.debug("Successful token response: " + result.id); 1.411 + cb(null, { 1.412 + id: result.id, 1.413 + key: result.key, 1.414 + endpoint: result.api_endpoint, 1.415 + uid: result.uid, 1.416 + duration: result.duration, 1.417 + }); 1.418 + }, 1.419 + 1.420 + /* 1.421 + * The prefix used for all notifications sent by this module. This 1.422 + * allows the handler of notifications to be sure they are handling 1.423 + * notifications for the service they expect. 1.424 + * 1.425 + * If not set, no notifications will be sent. 1.426 + */ 1.427 + observerPrefix: null, 1.428 + 1.429 + // Given an optional header value, notify that a backoff has been requested. 1.430 + _maybeNotifyBackoff: function (response, headerName) { 1.431 + if (!this.observerPrefix) { 1.432 + return; 1.433 + } 1.434 + let headerVal = response.headers[headerName]; 1.435 + if (!headerVal) { 1.436 + return; 1.437 + } 1.438 + let backoffInterval; 1.439 + try { 1.440 + backoffInterval = parseInt(headerVal, 10); 1.441 + } catch (ex) { 1.442 + this._log.error("TokenServer response had invalid backoff value in '" + 1.443 + headerName + "' header: " + headerVal); 1.444 + return; 1.445 + } 1.446 + Observers.notify(this.observerPrefix + ":backoff:interval", backoffInterval); 1.447 + }, 1.448 + 1.449 + // override points for testing. 1.450 + newRESTRequest: function(url) { 1.451 + return new RESTRequest(url); 1.452 + } 1.453 +};