michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this file, michael@0: * You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: this.EXPORTED_SYMBOLS = [ michael@0: "TokenServerClient", michael@0: "TokenServerClientError", michael@0: "TokenServerClientNetworkError", michael@0: "TokenServerClientServerError", michael@0: ]; michael@0: michael@0: const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; michael@0: michael@0: Cu.import("resource://gre/modules/Preferences.jsm"); michael@0: Cu.import("resource://gre/modules/Log.jsm"); michael@0: Cu.import("resource://services-common/rest.js"); michael@0: Cu.import("resource://services-common/utils.js"); michael@0: Cu.import("resource://services-common/observers.js"); michael@0: michael@0: const Prefs = new Preferences("services.common.tokenserverclient."); michael@0: michael@0: /** michael@0: * Represents a TokenServerClient error that occurred on the client. michael@0: * michael@0: * This is the base type for all errors raised by client operations. michael@0: * michael@0: * @param message michael@0: * (string) Error message. michael@0: */ michael@0: this.TokenServerClientError = function TokenServerClientError(message) { michael@0: this.name = "TokenServerClientError"; michael@0: this.message = message || "Client error."; michael@0: } michael@0: TokenServerClientError.prototype = new Error(); michael@0: TokenServerClientError.prototype.constructor = TokenServerClientError; michael@0: TokenServerClientError.prototype._toStringFields = function() { michael@0: return {message: this.message}; michael@0: } michael@0: TokenServerClientError.prototype.toString = function() { michael@0: return this.name + "(" + JSON.stringify(this._toStringFields()) + ")"; michael@0: } michael@0: michael@0: /** michael@0: * Represents a TokenServerClient error that occurred in the network layer. michael@0: * michael@0: * @param error michael@0: * The underlying error thrown by the network layer. michael@0: */ michael@0: this.TokenServerClientNetworkError = michael@0: function TokenServerClientNetworkError(error) { michael@0: this.name = "TokenServerClientNetworkError"; michael@0: this.error = error; michael@0: } michael@0: TokenServerClientNetworkError.prototype = new TokenServerClientError(); michael@0: TokenServerClientNetworkError.prototype.constructor = michael@0: TokenServerClientNetworkError; michael@0: TokenServerClientNetworkError.prototype._toStringFields = function() { michael@0: return {error: this.error}; michael@0: } michael@0: michael@0: /** michael@0: * Represents a TokenServerClient error that occurred on the server. michael@0: * michael@0: * This type will be encountered for all non-200 response codes from the michael@0: * server. The type of error is strongly enumerated and is stored in the michael@0: * `cause` property. This property can have the following string values: michael@0: * michael@0: * conditions-required -- The server is requesting that the client michael@0: * agree to service conditions before it can obtain a token. The michael@0: * conditions that must be presented to the user and agreed to are in michael@0: * the `urls` mapping on the instance. Keys of this mapping are michael@0: * identifiers. Values are string URLs. michael@0: * michael@0: * invalid-credentials -- A token could not be obtained because michael@0: * the credentials presented by the client were invalid. michael@0: * michael@0: * unknown-service -- The requested service was not found. michael@0: * michael@0: * malformed-request -- The server rejected the request because it michael@0: * was invalid. If you see this, code in this file is likely wrong. michael@0: * michael@0: * malformed-response -- The response from the server was not what was michael@0: * expected. michael@0: * michael@0: * general -- A general server error has occurred. Clients should michael@0: * interpret this as an opaque failure. michael@0: * michael@0: * @param message michael@0: * (string) Error message. michael@0: */ michael@0: this.TokenServerClientServerError = michael@0: function TokenServerClientServerError(message, cause="general") { michael@0: this.now = new Date().toISOString(); // may be useful to diagnose time-skew issues. michael@0: this.name = "TokenServerClientServerError"; michael@0: this.message = message || "Server error."; michael@0: this.cause = cause; michael@0: } michael@0: TokenServerClientServerError.prototype = new TokenServerClientError(); michael@0: TokenServerClientServerError.prototype.constructor = michael@0: TokenServerClientServerError; michael@0: michael@0: TokenServerClientServerError.prototype._toStringFields = function() { michael@0: let fields = { michael@0: now: this.now, michael@0: message: this.message, michael@0: cause: this.cause, michael@0: }; michael@0: if (this.response) { michael@0: fields.response_body = this.response.body; michael@0: fields.response_headers = this.response.headers; michael@0: fields.response_status = this.response.status; michael@0: } michael@0: return fields; michael@0: }; michael@0: michael@0: /** michael@0: * Represents a client to the Token Server. michael@0: * michael@0: * http://docs.services.mozilla.com/token/index.html michael@0: * michael@0: * The Token Server supports obtaining tokens for arbitrary apps by michael@0: * constructing URI paths of the form /. However, the service michael@0: * discovery mechanism emphasizes the use of full URIs and tries to not force michael@0: * the client to manipulate URIs. This client currently enforces this practice michael@0: * by not implementing an API which would perform URI manipulation. michael@0: * michael@0: * If you are tempted to implement this API in the future, consider this your michael@0: * warning that you may be doing it wrong and that you should store full URIs michael@0: * instead. michael@0: * michael@0: * Areas to Improve: michael@0: * michael@0: * - The server sends a JSON response on error. The client does not currently michael@0: * parse this. It might be convenient if it did. michael@0: * - Currently most non-200 status codes are rolled into one error type. It michael@0: * might be helpful if callers had a richer API that communicated who was michael@0: * at fault (e.g. differentiating a 503 from a 401). michael@0: */ michael@0: this.TokenServerClient = function TokenServerClient() { michael@0: this._log = Log.repository.getLogger("Common.TokenServerClient"); michael@0: this._log.level = Log.Level[Prefs.get("logger.level")]; michael@0: } michael@0: TokenServerClient.prototype = { michael@0: /** michael@0: * Logger instance. michael@0: */ michael@0: _log: null, michael@0: michael@0: /** michael@0: * Obtain a token from a BrowserID assertion against a specific URL. michael@0: * michael@0: * This asynchronously obtains the token. The callback receives 2 arguments: michael@0: * michael@0: * (TokenServerClientError | null) If no token could be obtained, this michael@0: * will be a TokenServerClientError instance describing why. The michael@0: * type seen defines the type of error encountered. If an HTTP response michael@0: * was seen, a RESTResponse instance will be stored in the `response` michael@0: * property of this object. If there was no error and a token is michael@0: * available, this will be null. michael@0: * michael@0: * (map | null) On success, this will be a map containing the results from michael@0: * the server. If there was an error, this will be null. The map has the michael@0: * following properties: michael@0: * michael@0: * id (string) HTTP MAC public key identifier. michael@0: * key (string) HTTP MAC shared symmetric key. michael@0: * endpoint (string) URL where service can be connected to. michael@0: * uid (string) user ID for requested service. michael@0: * duration (string) the validity duration of the issued token. michael@0: * michael@0: * Terms of Service Acceptance michael@0: * --------------------------- michael@0: * michael@0: * Some services require users to accept terms of service before they can michael@0: * obtain a token. If a service requires ToS acceptance, the error passed michael@0: * to the callback will be a `TokenServerClientServerError` with the michael@0: * `cause` property set to "conditions-required". The `urls` property of that michael@0: * instance will be a map of string keys to string URL values. The user-agent michael@0: * should prompt the user to accept the content at these URLs. michael@0: * michael@0: * Clients signify acceptance of the terms of service by sending a token michael@0: * request with additional metadata. This is controlled by the michael@0: * `conditionsAccepted` argument to this function. Clients only need to set michael@0: * this flag once per service and the server remembers acceptance. If michael@0: * the conditions for the service change, the server may request michael@0: * clients agree to terms again. Therefore, clients should always be michael@0: * prepared to handle a conditions required response. michael@0: * michael@0: * Clients should not blindly send acceptance to conditions. Instead, clients michael@0: * should set `conditionsAccepted` if and only if the server asks for michael@0: * acceptance, the conditions are displayed to the user, and the user agrees michael@0: * to them. michael@0: * michael@0: * Example Usage michael@0: * ------------- michael@0: * michael@0: * let client = new TokenServerClient(); michael@0: * let assertion = getBrowserIDAssertionFromSomewhere(); michael@0: * let url = "https://token.services.mozilla.com/1.0/sync/2.0"; michael@0: * michael@0: * client.getTokenFromBrowserIDAssertion(url, assertion, michael@0: * function onResponse(error, result) { michael@0: * if (error) { michael@0: * if (error.cause == "conditions-required") { michael@0: * promptConditionsAcceptance(error.urls, function onAccept() { michael@0: * client.getTokenFromBrowserIDAssertion(url, assertion, michael@0: * onResponse, true); michael@0: * } michael@0: * return; michael@0: * } michael@0: * michael@0: * // Do other error handling. michael@0: * return; michael@0: * } michael@0: * michael@0: * let { michael@0: * id: id, key: key, uid: uid, endpoint: endpoint, duration: duration michael@0: * } = result; michael@0: * // Do stuff with data and carry on. michael@0: * }); michael@0: * michael@0: * @param url michael@0: * (string) URL to fetch token from. michael@0: * @param assertion michael@0: * (string) BrowserID assertion to exchange token for. michael@0: * @param cb michael@0: * (function) Callback to be invoked with result of operation. michael@0: * @param conditionsAccepted michael@0: * (bool) Whether to send acceptance to service conditions. michael@0: */ michael@0: getTokenFromBrowserIDAssertion: michael@0: function getTokenFromBrowserIDAssertion(url, assertion, cb, addHeaders={}) { michael@0: if (!url) { michael@0: throw new TokenServerClientError("url argument is not valid."); michael@0: } michael@0: michael@0: if (!assertion) { michael@0: throw new TokenServerClientError("assertion argument is not valid."); michael@0: } michael@0: michael@0: if (!cb) { michael@0: throw new TokenServerClientError("cb argument is not valid."); michael@0: } michael@0: michael@0: this._log.debug("Beginning BID assertion exchange: " + url); michael@0: michael@0: let req = this.newRESTRequest(url); michael@0: req.setHeader("Accept", "application/json"); michael@0: req.setHeader("Authorization", "BrowserID " + assertion); michael@0: michael@0: for (let header in addHeaders) { michael@0: req.setHeader(header, addHeaders[header]); michael@0: } michael@0: michael@0: let client = this; michael@0: req.get(function onResponse(error) { michael@0: if (error) { michael@0: cb(new TokenServerClientNetworkError(error), null); michael@0: return; michael@0: } michael@0: michael@0: let self = this; michael@0: function callCallback(error, result) { michael@0: if (!cb) { michael@0: self._log.warn("Callback already called! Did it throw?"); michael@0: return; michael@0: } michael@0: michael@0: try { michael@0: cb(error, result); michael@0: } catch (ex) { michael@0: self._log.warn("Exception when calling user-supplied callback: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: } michael@0: michael@0: cb = null; michael@0: } michael@0: michael@0: try { michael@0: client._processTokenResponse(this.response, callCallback); michael@0: } catch (ex) { michael@0: this._log.warn("Error processing token server response: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: michael@0: let error = new TokenServerClientError(ex); michael@0: error.response = this.response; michael@0: callCallback(error, null); michael@0: } michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Handler to process token request responses. michael@0: * michael@0: * @param response michael@0: * RESTResponse from token HTTP request. michael@0: * @param cb michael@0: * The original callback passed to the public API. michael@0: */ michael@0: _processTokenResponse: function processTokenResponse(response, cb) { michael@0: this._log.debug("Got token response: " + response.status); michael@0: michael@0: // Responses should *always* be JSON, even in the case of 4xx and 5xx michael@0: // errors. If we don't see JSON, the server is likely very unhappy. michael@0: let ct = response.headers["content-type"] || ""; michael@0: if (ct != "application/json" && !ct.startsWith("application/json;")) { michael@0: this._log.warn("Did not receive JSON response. Misconfigured server?"); michael@0: this._log.debug("Content-Type: " + ct); michael@0: this._log.debug("Body: " + response.body); michael@0: michael@0: let error = new TokenServerClientServerError("Non-JSON response.", michael@0: "malformed-response"); michael@0: error.response = response; michael@0: cb(error, null); michael@0: return; michael@0: } michael@0: michael@0: let result; michael@0: try { michael@0: result = JSON.parse(response.body); michael@0: } catch (ex) { michael@0: this._log.warn("Invalid JSON returned by server: " + response.body); michael@0: let error = new TokenServerClientServerError("Malformed JSON.", michael@0: "malformed-response"); michael@0: error.response = response; michael@0: cb(error, null); michael@0: return; michael@0: } michael@0: michael@0: // Any response status can have X-Backoff or X-Weave-Backoff headers. michael@0: this._maybeNotifyBackoff(response, "x-weave-backoff"); michael@0: this._maybeNotifyBackoff(response, "x-backoff"); michael@0: michael@0: // The service shouldn't have any 3xx, so we don't need to handle those. michael@0: if (response.status != 200) { michael@0: // We /should/ have a Cornice error report in the JSON. We log that to michael@0: // help with debugging. michael@0: if ("errors" in result) { michael@0: // This could throw, but this entire function is wrapped in a try. If michael@0: // the server is sending something not an array of objects, it has michael@0: // failed to keep its contract with us and there is little we can do. michael@0: for (let error of result.errors) { michael@0: this._log.info("Server-reported error: " + JSON.stringify(error)); michael@0: } michael@0: } michael@0: michael@0: let error = new TokenServerClientServerError(); michael@0: error.response = response; michael@0: michael@0: if (response.status == 400) { michael@0: error.message = "Malformed request."; michael@0: error.cause = "malformed-request"; michael@0: } else if (response.status == 401) { michael@0: // Cause can be invalid-credentials, invalid-timestamp, or michael@0: // invalid-generation. michael@0: error.message = "Authentication failed."; michael@0: error.cause = result.status; michael@0: } michael@0: michael@0: // 403 should represent a "condition acceptance needed" response. michael@0: // michael@0: // The extra validation of "urls" is important. We don't want to signal michael@0: // conditions required unless we are absolutely sure that is what the michael@0: // server is asking for. michael@0: else if (response.status == 403) { michael@0: if (!("urls" in result)) { michael@0: this._log.warn("403 response without proper fields!"); michael@0: this._log.warn("Response body: " + response.body); michael@0: michael@0: error.message = "Missing JSON fields."; michael@0: error.cause = "malformed-response"; michael@0: } else if (typeof(result.urls) != "object") { michael@0: error.message = "urls field is not a map."; michael@0: error.cause = "malformed-response"; michael@0: } else { michael@0: error.message = "Conditions must be accepted."; michael@0: error.cause = "conditions-required"; michael@0: error.urls = result.urls; michael@0: } michael@0: } else if (response.status == 404) { michael@0: error.message = "Unknown service."; michael@0: error.cause = "unknown-service"; michael@0: } michael@0: michael@0: // A Retry-After header should theoretically only appear on a 503, but michael@0: // we'll look for it on any error response. michael@0: this._maybeNotifyBackoff(response, "retry-after"); michael@0: michael@0: cb(error, null); michael@0: return; michael@0: } michael@0: michael@0: for (let k of ["id", "key", "api_endpoint", "uid", "duration"]) { michael@0: if (!(k in result)) { michael@0: let error = new TokenServerClientServerError("Expected key not " + michael@0: " present in result: " + michael@0: k); michael@0: error.cause = "malformed-response"; michael@0: error.response = response; michael@0: cb(error, null); michael@0: return; michael@0: } michael@0: } michael@0: michael@0: this._log.debug("Successful token response: " + result.id); michael@0: cb(null, { michael@0: id: result.id, michael@0: key: result.key, michael@0: endpoint: result.api_endpoint, michael@0: uid: result.uid, michael@0: duration: result.duration, michael@0: }); michael@0: }, michael@0: michael@0: /* michael@0: * The prefix used for all notifications sent by this module. This michael@0: * allows the handler of notifications to be sure they are handling michael@0: * notifications for the service they expect. michael@0: * michael@0: * If not set, no notifications will be sent. michael@0: */ michael@0: observerPrefix: null, michael@0: michael@0: // Given an optional header value, notify that a backoff has been requested. michael@0: _maybeNotifyBackoff: function (response, headerName) { michael@0: if (!this.observerPrefix) { michael@0: return; michael@0: } michael@0: let headerVal = response.headers[headerName]; michael@0: if (!headerVal) { michael@0: return; michael@0: } michael@0: let backoffInterval; michael@0: try { michael@0: backoffInterval = parseInt(headerVal, 10); michael@0: } catch (ex) { michael@0: this._log.error("TokenServer response had invalid backoff value in '" + michael@0: headerName + "' header: " + headerVal); michael@0: return; michael@0: } michael@0: Observers.notify(this.observerPrefix + ":backoff:interval", backoffInterval); michael@0: }, michael@0: michael@0: // override points for testing. michael@0: newRESTRequest: function(url) { michael@0: return new RESTRequest(url); michael@0: } michael@0: };