diff options
Diffstat (limited to '')
-rw-r--r-- | services/fxaccounts/FxAccountsProfileClient.jsm | 305 |
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()) + ")"; +}; |