summaryrefslogtreecommitdiff
path: root/services/fxaccounts/FxAccountsWebChannel.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'services/fxaccounts/FxAccountsWebChannel.jsm')
-rw-r--r--services/fxaccounts/FxAccountsWebChannel.jsm474
1 files changed, 474 insertions, 0 deletions
diff --git a/services/fxaccounts/FxAccountsWebChannel.jsm b/services/fxaccounts/FxAccountsWebChannel.jsm
new file mode 100644
index 0000000000..810d93c651
--- /dev/null
+++ b/services/fxaccounts/FxAccountsWebChannel.jsm
@@ -0,0 +1,474 @@
+/* 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/. */
+
+/**
+ * Firefox Accounts Web Channel.
+ *
+ * Uses the WebChannel component to receive messages
+ * about account state changes.
+ */
+
+this.EXPORTED_SYMBOLS = ["EnsureFxAccountsWebChannel"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+ "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "WebChannel",
+ "resource://gre/modules/WebChannel.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
+ "resource://gre/modules/FxAccounts.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsStorageManagerCanStoreField",
+ "resource://gre/modules/FxAccountsStorage.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Weave",
+ "resource://services-sync/main.js");
+
+const COMMAND_PROFILE_CHANGE = "profile:change";
+const COMMAND_CAN_LINK_ACCOUNT = "fxaccounts:can_link_account";
+const COMMAND_LOGIN = "fxaccounts:login";
+const COMMAND_LOGOUT = "fxaccounts:logout";
+const COMMAND_DELETE = "fxaccounts:delete";
+const COMMAND_SYNC_PREFERENCES = "fxaccounts:sync_preferences";
+const COMMAND_CHANGE_PASSWORD = "fxaccounts:change_password";
+
+const PREF_LAST_FXA_USER = "identity.fxaccounts.lastSignedInUserHash";
+const PREF_SYNC_SHOW_CUSTOMIZATION = "services.sync-setup.ui.showCustomizationDialog";
+
+/**
+ * A helper function that extracts the message and stack from an error object.
+ * Returns a `{ message, stack }` tuple. `stack` will be null if the error
+ * doesn't have a stack trace.
+ */
+function getErrorDetails(error) {
+ let details = { message: String(error), stack: null };
+
+ // Adapted from Console.jsm.
+ if (error.stack) {
+ let frames = [];
+ for (let frame = error.stack; frame; frame = frame.caller) {
+ frames.push(String(frame).padStart(4));
+ }
+ details.stack = frames.join("\n");
+ }
+
+ return details;
+}
+
+/**
+ * Create a new FxAccountsWebChannel to listen for account updates
+ *
+ * @param {Object} options Options
+ * @param {Object} options
+ * @param {String} options.content_uri
+ * The FxA Content server uri
+ * @param {String} options.channel_id
+ * The ID of the WebChannel
+ * @param {String} options.helpers
+ * Helpers functions. Should only be passed in for testing.
+ * @constructor
+ */
+this.FxAccountsWebChannel = function(options) {
+ if (!options) {
+ throw new Error("Missing configuration options");
+ }
+ if (!options["content_uri"]) {
+ throw new Error("Missing 'content_uri' option");
+ }
+ this._contentUri = options.content_uri;
+
+ if (!options["channel_id"]) {
+ throw new Error("Missing 'channel_id' option");
+ }
+ this._webChannelId = options.channel_id;
+
+ // options.helpers is only specified by tests.
+ this._helpers = options.helpers || new FxAccountsWebChannelHelpers(options);
+
+ this._setupChannel();
+};
+
+this.FxAccountsWebChannel.prototype = {
+ /**
+ * WebChannel that is used to communicate with content page
+ */
+ _channel: null,
+
+ /**
+ * Helpers interface that does the heavy lifting.
+ */
+ _helpers: null,
+
+ /**
+ * WebChannel ID.
+ */
+ _webChannelId: null,
+ /**
+ * WebChannel origin, used to validate origin of messages
+ */
+ _webChannelOrigin: null,
+
+ /**
+ * Release all resources that are in use.
+ */
+ tearDown() {
+ this._channel.stopListening();
+ this._channel = null;
+ this._channelCallback = null;
+ },
+
+ /**
+ * Configures and registers a new WebChannel
+ *
+ * @private
+ */
+ _setupChannel() {
+ // if this.contentUri is present but not a valid URI, then this will throw an error.
+ try {
+ this._webChannelOrigin = Services.io.newURI(this._contentUri, null, null);
+ this._registerChannel();
+ } catch (e) {
+ log.error(e);
+ throw e;
+ }
+ },
+
+ _receiveMessage(message, sendingContext) {
+ let command = message.command;
+ let data = message.data;
+
+ switch (command) {
+ case COMMAND_PROFILE_CHANGE:
+ Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, data.uid);
+ break;
+ case COMMAND_LOGIN:
+ this._helpers.login(data).catch(error =>
+ this._sendError(error, message, sendingContext));
+ break;
+ case COMMAND_LOGOUT:
+ case COMMAND_DELETE:
+ this._helpers.logout(data.uid).catch(error =>
+ this._sendError(error, message, sendingContext));
+ break;
+ case COMMAND_CAN_LINK_ACCOUNT:
+ let canLinkAccount = this._helpers.shouldAllowRelink(data.email);
+
+ let response = {
+ command: command,
+ messageId: message.messageId,
+ data: { ok: canLinkAccount }
+ };
+
+ log.debug("FxAccountsWebChannel response", response);
+ this._channel.send(response, sendingContext);
+ break;
+ case COMMAND_SYNC_PREFERENCES:
+ this._helpers.openSyncPreferences(sendingContext.browser, data.entryPoint);
+ break;
+ case COMMAND_CHANGE_PASSWORD:
+ this._helpers.changePassword(data).catch(error =>
+ this._sendError(error, message, sendingContext));
+ break;
+ default:
+ log.warn("Unrecognized FxAccountsWebChannel command", command);
+ break;
+ }
+ },
+
+ _sendError(error, incomingMessage, sendingContext) {
+ log.error("Failed to handle FxAccountsWebChannel message", error);
+ this._channel.send({
+ command: incomingMessage.command,
+ messageId: incomingMessage.messageId,
+ data: {
+ error: getErrorDetails(error),
+ },
+ }, sendingContext);
+ },
+
+ /**
+ * Create a new channel with the WebChannelBroker, setup a callback listener
+ * @private
+ */
+ _registerChannel() {
+ /**
+ * Processes messages that are called back from the FxAccountsChannel
+ *
+ * @param webChannelId {String}
+ * Command webChannelId
+ * @param message {Object}
+ * Command message
+ * @param sendingContext {Object}
+ * Message sending context.
+ * @param sendingContext.browser {browser}
+ * The <browser> object that captured the
+ * WebChannelMessageToChrome.
+ * @param sendingContext.eventTarget {EventTarget}
+ * The <EventTarget> where the message was sent.
+ * @param sendingContext.principal {Principal}
+ * The <Principal> of the EventTarget where the message was sent.
+ * @private
+ *
+ */
+ let listener = (webChannelId, message, sendingContext) => {
+ if (message) {
+ log.debug("FxAccountsWebChannel message received", message.command);
+ if (logPII) {
+ log.debug("FxAccountsWebChannel message details", message);
+ }
+ try {
+ this._receiveMessage(message, sendingContext);
+ } catch (error) {
+ this._sendError(error, message, sendingContext);
+ }
+ }
+ };
+
+ this._channelCallback = listener;
+ this._channel = new WebChannel(this._webChannelId, this._webChannelOrigin);
+ this._channel.listen(listener);
+ log.debug("FxAccountsWebChannel registered: " + this._webChannelId + " with origin " + this._webChannelOrigin.prePath);
+ }
+};
+
+this.FxAccountsWebChannelHelpers = function(options) {
+ options = options || {};
+
+ this._fxAccounts = options.fxAccounts || fxAccounts;
+};
+
+this.FxAccountsWebChannelHelpers.prototype = {
+ // If the last fxa account used for sync isn't this account, we display
+ // a modal dialog checking they really really want to do this...
+ // (This is sync-specific, so ideally would be in sync's identity module,
+ // but it's a little more seamless to do here, and sync is currently the
+ // only fxa consumer, so...
+ shouldAllowRelink(acctName) {
+ return !this._needRelinkWarning(acctName) ||
+ this._promptForRelink(acctName);
+ },
+
+ /**
+ * New users are asked in the content server whether they want to
+ * customize which data should be synced. The user is only shown
+ * the dialog listing the possible data types upon verification.
+ *
+ * Save a bit into prefs that is read on verification to see whether
+ * to show the list of data types that can be saved.
+ */
+ setShowCustomizeSyncPref(showCustomizeSyncPref) {
+ Services.prefs.setBoolPref(PREF_SYNC_SHOW_CUSTOMIZATION, showCustomizeSyncPref);
+ },
+
+ getShowCustomizeSyncPref() {
+ return Services.prefs.getBoolPref(PREF_SYNC_SHOW_CUSTOMIZATION);
+ },
+
+ /**
+ * stores sync login info it in the fxaccounts service
+ *
+ * @param accountData the user's account data and credentials
+ */
+ login(accountData) {
+ if (accountData.customizeSync) {
+ this.setShowCustomizeSyncPref(true);
+ delete accountData.customizeSync;
+ }
+
+ if (accountData.declinedSyncEngines) {
+ let declinedSyncEngines = accountData.declinedSyncEngines;
+ log.debug("Received declined engines", declinedSyncEngines);
+ Weave.Service.engineManager.setDeclined(declinedSyncEngines);
+ declinedSyncEngines.forEach(engine => {
+ Services.prefs.setBoolPref("services.sync.engine." + engine, false);
+ });
+
+ // if we got declinedSyncEngines that means we do not need to show the customize screen.
+ this.setShowCustomizeSyncPref(false);
+ delete accountData.declinedSyncEngines;
+ }
+
+ // the user has already been shown the "can link account"
+ // screen. No need to keep this data around.
+ delete accountData.verifiedCanLinkAccount;
+
+ // Remember who it was so we can log out next time.
+ this.setPreviousAccountNameHashPref(accountData.email);
+
+ // A sync-specific hack - we want to ensure sync has been initialized
+ // before we set the signed-in user.
+ let xps = Cc["@mozilla.org/weave/service;1"]
+ .getService(Ci.nsISupports)
+ .wrappedJSObject;
+ return xps.whenLoaded().then(() => {
+ return this._fxAccounts.setSignedInUser(accountData);
+ });
+ },
+
+ /**
+ * logout the fxaccounts service
+ *
+ * @param the uid of the account which have been logged out
+ */
+ logout(uid) {
+ return fxAccounts.getSignedInUser().then(userData => {
+ if (userData.uid === uid) {
+ // true argument is `localOnly`, because server-side stuff
+ // has already been taken care of by the content server
+ return fxAccounts.signOut(true);
+ }
+ });
+ },
+
+ changePassword(credentials) {
+ // If |credentials| has fields that aren't handled by accounts storage,
+ // updateUserAccountData will throw - mainly to prevent errors in code
+ // that hard-codes field names.
+ // However, in this case the field names aren't really in our control.
+ // We *could* still insist the server know what fields names are valid,
+ // but that makes life difficult for the server when Firefox adds new
+ // features (ie, new fields) - forcing the server to track a map of
+ // versions to supported field names doesn't buy us much.
+ // So we just remove field names we know aren't handled.
+ let newCredentials = {
+ deviceId: null
+ };
+ for (let name of Object.keys(credentials)) {
+ if (name == "email" || name == "uid" || FxAccountsStorageManagerCanStoreField(name)) {
+ newCredentials[name] = credentials[name];
+ } else {
+ log.info("changePassword ignoring unsupported field", name);
+ }
+ }
+ return this._fxAccounts.updateUserAccountData(newCredentials)
+ .then(() => this._fxAccounts.updateDeviceRegistration());
+ },
+
+ /**
+ * Get the hash of account name of the previously signed in account
+ */
+ getPreviousAccountNameHashPref() {
+ try {
+ return Services.prefs.getComplexValue(PREF_LAST_FXA_USER, Ci.nsISupportsString).data;
+ } catch (_) {
+ return "";
+ }
+ },
+
+ /**
+ * Given an account name, set the hash of the previously signed in account
+ *
+ * @param acctName the account name of the user's account.
+ */
+ setPreviousAccountNameHashPref(acctName) {
+ let string = Cc["@mozilla.org/supports-string;1"]
+ .createInstance(Ci.nsISupportsString);
+ string.data = this.sha256(acctName);
+ Services.prefs.setComplexValue(PREF_LAST_FXA_USER, Ci.nsISupportsString, string);
+ },
+
+ /**
+ * Given a string, returns the SHA265 hash in base64
+ */
+ sha256(str) {
+ let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
+ .createInstance(Ci.nsIScriptableUnicodeConverter);
+ converter.charset = "UTF-8";
+ // Data is an array of bytes.
+ let data = converter.convertToByteArray(str, {});
+ let hasher = Cc["@mozilla.org/security/hash;1"]
+ .createInstance(Ci.nsICryptoHash);
+ hasher.init(hasher.SHA256);
+ hasher.update(data, data.length);
+
+ return hasher.finish(true);
+ },
+
+ /**
+ * Open Sync Preferences in the current tab of the browser
+ *
+ * @param {Object} browser the browser in which to open preferences
+ * @param {String} [entryPoint] entryPoint to use for logging
+ */
+ openSyncPreferences(browser, entryPoint) {
+ let uri = "about:preferences";
+ if (entryPoint) {
+ uri += "?entrypoint=" + encodeURIComponent(entryPoint);
+ }
+ uri += "#sync";
+
+ browser.loadURI(uri);
+ },
+
+ /**
+ * If a user signs in using a different account, the data from the
+ * previous account and the new account will be merged. Ask the user
+ * if they want to continue.
+ *
+ * @private
+ */
+ _needRelinkWarning(acctName) {
+ let prevAcctHash = this.getPreviousAccountNameHashPref();
+ return prevAcctHash && prevAcctHash != this.sha256(acctName);
+ },
+
+ /**
+ * Show the user a warning dialog that the data from the previous account
+ * and the new account will be merged.
+ *
+ * @private
+ */
+ _promptForRelink(acctName) {
+ let sb = Services.strings.createBundle("chrome://browser/locale/syncSetup.properties");
+ let continueLabel = sb.GetStringFromName("continue.label");
+ let title = sb.GetStringFromName("relinkVerify.title");
+ let description = sb.formatStringFromName("relinkVerify.description",
+ [acctName], 1);
+ let body = sb.GetStringFromName("relinkVerify.heading") +
+ "\n\n" + description;
+ let ps = Services.prompt;
+ let buttonFlags = (ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING) +
+ (ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL) +
+ ps.BUTTON_POS_1_DEFAULT;
+
+ // If running in context of the browser chrome, window does not exist.
+ var targetWindow = typeof window === 'undefined' ? null : window;
+ let pressed = Services.prompt.confirmEx(targetWindow, title, body, buttonFlags,
+ continueLabel, null, null, null,
+ {});
+ return pressed === 0; // 0 is the "continue" button
+ }
+};
+
+var singleton;
+// The entry-point for this module, which ensures only one of our channels is
+// ever created - we require this because the WebChannel is global in scope
+// (eg, it uses the observer service to tell interested parties of interesting
+// things) and allowing multiple channels would cause such notifications to be
+// sent multiple times.
+this.EnsureFxAccountsWebChannel = function() {
+ let contentUri = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.webchannel.uri");
+ if (singleton && singleton._contentUri !== contentUri) {
+ singleton.tearDown();
+ singleton = null;
+ }
+ if (!singleton) {
+ try {
+ if (contentUri) {
+ // The FxAccountsWebChannel listens for events and updates
+ // the state machine accordingly.
+ singleton = new this.FxAccountsWebChannel({
+ content_uri: contentUri,
+ channel_id: WEBCHANNEL_ID,
+ });
+ } else {
+ log.warn("FxA WebChannel functionaly is disabled due to no URI pref.");
+ }
+ } catch (ex) {
+ log.error("Failed to create FxA WebChannel", ex);
+ }
+ }
+}