summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts/FxAccountsProfileClient.jsm
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--services/fxaccounts/FxAccountsProfileClient.jsm305
1 files changed, 305 insertions, 0 deletions
diff --git a/services/fxaccounts/FxAccountsProfileClient.jsm b/services/fxaccounts/FxAccountsProfileClient.jsm
new file mode 100644
index 0000000000..798e859782
--- /dev/null
+++ b/services/fxaccounts/FxAccountsProfileClient.jsm
@@ -0,0 +1,305 @@
+/* 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/. */
+
+/**
+ * A client to fetch profile information for a Firefox Account.
+ */
+"use strict;";
+
+var EXPORTED_SYMBOLS = [
+ "FxAccountsProfileClient",
+ "FxAccountsProfileClientError",
+];
+
+const {
+ ERRNO_NETWORK,
+ ERRNO_PARSE,
+ ERRNO_UNKNOWN_ERROR,
+ ERROR_CODE_METHOD_NOT_ALLOWED,
+ ERROR_MSG_METHOD_NOT_ALLOWED,
+ ERROR_NETWORK,
+ ERROR_PARSE,
+ ERROR_UNKNOWN,
+ log,
+ SCOPE_PROFILE,
+ SCOPE_PROFILE_WRITE,
+} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
+const { fxAccounts } = ChromeUtils.import(
+ "resource://gre/modules/FxAccounts.jsm"
+);
+const { RESTRequest } = ChromeUtils.import(
+ "resource://services-common/rest.js"
+);
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+/**
+ * Create a new FxAccountsProfileClient to be able to fetch Firefox Account profile information.
+ *
+ * @param {Object} options Options
+ * @param {String} options.serverURL
+ * The URL of the profile server to query.
+ * Example: https://profile.accounts.firefox.com/v1
+ * @param {String} options.token
+ * The bearer token to access the profile server
+ * @constructor
+ */
+var FxAccountsProfileClient = function(options) {
+ if (!options || !options.serverURL) {
+ throw new Error("Missing 'serverURL' configuration option");
+ }
+
+ this.fxai = options.fxai || fxAccounts._internal;
+
+ try {
+ this.serverURL = new URL(options.serverURL);
+ } catch (e) {
+ throw new Error("Invalid 'serverURL'");
+ }
+ log.debug("FxAccountsProfileClient: Initialized");
+};
+
+FxAccountsProfileClient.prototype = {
+ /**
+ * {nsIURI}
+ * The server to fetch profile information from.
+ */
+ serverURL: null,
+
+ /**
+ * Interface for making remote requests.
+ */
+ _Request: RESTRequest,
+
+ /**
+ * Remote request helper which abstracts authentication away.
+ *
+ * @param {String} path
+ * Profile server path, i.e "/profile".
+ * @param {String} [method]
+ * Type of request, e.g. "GET".
+ * @param {String} [etag]
+ * Optional ETag used for caching purposes.
+ * @param {Object} [body]
+ * Optional request body, to be sent as application/json.
+ * @return Promise
+ * Resolves: {body: Object, etag: Object} Successful response from the Profile server.
+ * Rejects: {FxAccountsProfileClientError} Profile client error.
+ * @private
+ */
+ async _createRequest(path, method = "GET", etag = null, body = null) {
+ method = method.toUpperCase();
+ let token = await this._getTokenForRequest(method);
+ try {
+ return await this._rawRequest(path, method, token, etag, body);
+ } catch (ex) {
+ if (!(ex instanceof FxAccountsProfileClientError) || ex.code != 401) {
+ throw ex;
+ }
+ // it's an auth error - assume our token expired and retry.
+ log.info(
+ "Fetching the profile returned a 401 - revoking our token and retrying"
+ );
+ await this.fxai.removeCachedOAuthToken({ token });
+ token = await this._getTokenForRequest(method);
+ // and try with the new token - if that also fails then we fail after
+ // revoking the token.
+ try {
+ return await this._rawRequest(path, method, token, etag, body);
+ } catch (ex) {
+ if (!(ex instanceof FxAccountsProfileClientError) || ex.code != 401) {
+ throw ex;
+ }
+ log.info(
+ "Retry fetching the profile still returned a 401 - revoking our token and failing"
+ );
+ await this.fxai.removeCachedOAuthToken({ token });
+ throw ex;
+ }
+ }
+ },
+
+ /**
+ * Helper to get an OAuth token for a request.
+ *
+ * OAuth tokens are cached, so it's fine to call this for each request.
+ *
+ * @param {String} [method]
+ * Type of request, i.e "GET".
+ * @return Promise
+ * Resolves: Object containing "scope", "token" and "key" properties
+ * Rejects: {FxAccountsProfileClientError} Profile client error.
+ * @private
+ */
+ async _getTokenForRequest(method) {
+ let scope = SCOPE_PROFILE;
+ if (method === "POST") {
+ scope = SCOPE_PROFILE_WRITE;
+ }
+ return this.fxai.getOAuthToken({ scope });
+ },
+
+ /**
+ * Remote "raw" request helper - doesn't handle auth errors and tokens.
+ *
+ * @param {String} path
+ * Profile server path, i.e "/profile".
+ * @param {String} method
+ * Type of request, i.e "GET".
+ * @param {String} token
+ * @param {String} etag
+ * @param {Object} payload
+ * The payload of the request, if any.
+ * @return Promise
+ * Resolves: {body: Object, etag: Object} Successful response from the Profile server
+ or null if 304 is hit (same ETag).
+ * Rejects: {FxAccountsProfileClientError} Profile client error.
+ * @private
+ */
+ async _rawRequest(path, method, token, etag = null, payload = null) {
+ let profileDataUrl = this.serverURL + path;
+ let request = new this._Request(profileDataUrl);
+
+ request.setHeader("Authorization", "Bearer " + token);
+ request.setHeader("Accept", "application/json");
+ if (etag) {
+ request.setHeader("If-None-Match", etag);
+ }
+
+ if (method != "GET" && method != "POST") {
+ // method not supported
+ throw new FxAccountsProfileClientError({
+ error: ERROR_NETWORK,
+ errno: ERRNO_NETWORK,
+ code: ERROR_CODE_METHOD_NOT_ALLOWED,
+ message: ERROR_MSG_METHOD_NOT_ALLOWED,
+ });
+ }
+ try {
+ await request.dispatch(method, payload);
+ } catch (error) {
+ throw new FxAccountsProfileClientError({
+ error: ERROR_NETWORK,
+ errno: ERRNO_NETWORK,
+ message: error.toString(),
+ });
+ }
+
+ let body = null;
+ try {
+ if (request.response.status == 304) {
+ return null;
+ }
+ body = JSON.parse(request.response.body);
+ } catch (e) {
+ throw new FxAccountsProfileClientError({
+ error: ERROR_PARSE,
+ errno: ERRNO_PARSE,
+ code: request.response.status,
+ message: request.response.body,
+ });
+ }
+
+ // "response.success" means status code is 200
+ if (!request.response.success) {
+ throw new FxAccountsProfileClientError({
+ error: body.error || ERROR_UNKNOWN,
+ errno: body.errno || ERRNO_UNKNOWN_ERROR,
+ code: request.response.status,
+ message: body.message || body,
+ });
+ }
+ return {
+ body,
+ etag: request.response.headers.etag,
+ };
+ },
+
+ /**
+ * Retrieve user's profile from the server
+ *
+ * @param {String} [etag]
+ * Optional ETag used for caching purposes. (may generate a 304 exception)
+ * @return Promise
+ * Resolves: {body: Object, etag: Object} Successful response from the '/profile' endpoint.
+ * Rejects: {FxAccountsProfileClientError} profile client error.
+ */
+ fetchProfile(etag) {
+ log.debug("FxAccountsProfileClient: Requested profile");
+ return this._createRequest("/profile", "GET", etag);
+ },
+
+ /**
+ * Write an ecosystemAnonId value to the user's profile data on the server.
+ *
+ * This should be used only if the user's profile data does not already contain an
+ * ecosytemAnonId field, and it will reject with a "412 Precondition Failed" if there
+ * is one already present on the server.
+ *
+ * @param {String} [ecosystemAnonId]
+ * The generated ecosystemAnonId value to store on the server.
+ * @return Promise
+ * Resolves: {body: Object} Successful response from the '/ecosystem_anon_id' endpoint.
+ * Rejects: {FxAccountsProfileClientError} profile client error.
+ */
+ setEcosystemAnonId(ecosystemAnonId) {
+ log.debug("FxAccountsProfileClient: Setting ecosystemAnonId");
+ // This uses `If-None-Match: "*"` to prevent two concurrent clients from setting a value.
+ return this._createRequest("/ecosystem_anon_id", "POST", "*", {
+ ecosystemAnonId,
+ });
+ },
+};
+
+/**
+ * Normalized profile client errors
+ * @param {Object} [details]
+ * Error details object
+ * @param {number} [details.code]
+ * Error code
+ * @param {number} [details.errno]
+ * Error number
+ * @param {String} [details.error]
+ * Error description
+ * @param {String|null} [details.message]
+ * Error message
+ * @constructor
+ */
+var FxAccountsProfileClientError = function(details) {
+ details = details || {};
+
+ this.name = "FxAccountsProfileClientError";
+ this.code = details.code || null;
+ this.errno = details.errno || ERRNO_UNKNOWN_ERROR;
+ this.error = details.error || ERROR_UNKNOWN;
+ this.message = details.message || null;
+};
+
+/**
+ * Returns error object properties
+ *
+ * @returns {{name: *, code: *, errno: *, error: *, message: *}}
+ * @private
+ */
+FxAccountsProfileClientError.prototype._toStringFields = function() {
+ return {
+ name: this.name,
+ code: this.code,
+ errno: this.errno,
+ error: this.error,
+ message: this.message,
+ };
+};
+
+/**
+ * String representation of a profile client error
+ *
+ * @returns {String}
+ */
+FxAccountsProfileClientError.prototype.toString = function() {
+ return this.name + "(" + JSON.stringify(this._toStringFields()) + ")";
+};