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