tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

FxAccountsWebChannel.sys.mjs (44420B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 /**
      6 * Firefox Accounts Web Channel.
      7 *
      8 * Uses the WebChannel component to receive messages
      9 * about account state changes.
     10 */
     11 
     12 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
     13 
     14 import {
     15  COMMAND_PROFILE_CHANGE,
     16  COMMAND_LOGIN,
     17  COMMAND_LOGOUT,
     18  COMMAND_OAUTH,
     19  COMMAND_DELETE,
     20  COMMAND_CAN_LINK_ACCOUNT,
     21  COMMAND_SYNC_PREFERENCES,
     22  COMMAND_CHANGE_PASSWORD,
     23  COMMAND_FXA_STATUS,
     24  COMMAND_PAIR_HEARTBEAT,
     25  COMMAND_PAIR_SUPP_METADATA,
     26  COMMAND_PAIR_AUTHORIZE,
     27  COMMAND_PAIR_DECLINE,
     28  COMMAND_PAIR_COMPLETE,
     29  COMMAND_PAIR_PREFERENCES,
     30  COMMAND_FIREFOX_VIEW,
     31  OAUTH_CLIENT_ID,
     32  ON_PROFILE_CHANGE_NOTIFICATION,
     33  PREF_LAST_FXA_USER_UID,
     34  PREF_LAST_FXA_USER_EMAIL,
     35  WEBCHANNEL_ID,
     36  log,
     37  logPII,
     38 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
     39 import { SyncDisconnect } from "resource://services-sync/SyncDisconnect.sys.mjs";
     40 
     41 const lazy = {};
     42 
     43 ChromeUtils.defineESModuleGetters(lazy, {
     44  CryptoUtils: "moz-src:///services/crypto/modules/utils.sys.mjs",
     45  FxAccountsPairingFlow: "resource://gre/modules/FxAccountsPairing.sys.mjs",
     46  FxAccountsStorageManagerCanStoreField:
     47    "resource://gre/modules/FxAccountsStorage.sys.mjs",
     48  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     49  Weave: "resource://services-sync/main.sys.mjs",
     50  WebChannel: "resource://gre/modules/WebChannel.sys.mjs",
     51 });
     52 ChromeUtils.defineLazyGetter(lazy, "SelectableProfileService", () => {
     53  try {
     54    // Only available in Firefox.
     55    return ChromeUtils.importESModule(
     56      "resource:///modules/profiles/SelectableProfileService.sys.mjs"
     57    ).SelectableProfileService;
     58  } catch (ex) {
     59    return null;
     60  }
     61 });
     62 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
     63  return ChromeUtils.importESModule(
     64    "resource://gre/modules/FxAccounts.sys.mjs"
     65  ).getFxAccountsSingleton();
     66 });
     67 XPCOMUtils.defineLazyPreferenceGetter(
     68  lazy,
     69  "pairingEnabled",
     70  "identity.fxaccounts.pairing.enabled"
     71 );
     72 XPCOMUtils.defineLazyPreferenceGetter(
     73  lazy,
     74  "separatePrivilegedMozillaWebContentProcess",
     75  "browser.tabs.remote.separatePrivilegedMozillaWebContentProcess",
     76  false
     77 );
     78 XPCOMUtils.defineLazyPreferenceGetter(
     79  lazy,
     80  "separatedMozillaDomains",
     81  "browser.tabs.remote.separatedMozillaDomains",
     82  "",
     83  false,
     84  val => val.split(",")
     85 );
     86 XPCOMUtils.defineLazyPreferenceGetter(
     87  lazy,
     88  "accountServer",
     89  "identity.fxaccounts.remote.root",
     90  null,
     91  false,
     92  val => Services.io.newURI(val)
     93 );
     94 
     95 XPCOMUtils.defineLazyPreferenceGetter(
     96  lazy,
     97  "allowSyncMerge",
     98  "browser.profiles.sync.allow-danger-merge",
     99  false
    100 );
    101 
    102 ChromeUtils.defineLazyGetter(lazy, "l10n", function () {
    103  return new Localization(["browser/sync.ftl", "branding/brand.ftl"], true);
    104 });
    105 
    106 // These engines will be displayed to the user to pick which they would like to
    107 // use.
    108 const CHOOSE_WHAT_TO_SYNC_ALWAYS_AVAILABLE = [
    109  "addons",
    110  "bookmarks",
    111  "history",
    112  "passwords",
    113  "prefs",
    114  "tabs",
    115 ];
    116 
    117 // Engines which we need to inspect a pref to see if they are available, and
    118 // possibly have their default preference value to disabled.
    119 const CHOOSE_WHAT_TO_SYNC_OPTIONALLY_AVAILABLE = ["addresses", "creditcards"];
    120 
    121 /**
    122 * A helper function that extracts the message and stack from an error object.
    123 * Returns a `{ message, stack }` tuple. `stack` will be null if the error
    124 * doesn't have a stack trace.
    125 */
    126 function getErrorDetails(error) {
    127  // Replace anything that looks like it might be a filepath on Windows or Unix
    128  let cleanMessage = String(error)
    129    .replace(/\\.*\\/gm, "[REDACTED]")
    130    .replace(/\/.*\//gm, "[REDACTED]");
    131  let details = { message: cleanMessage, stack: null };
    132 
    133  // Adapted from Console.sys.mjs.
    134  if (error.stack) {
    135    let frames = [];
    136    for (let frame = error.stack; frame; frame = frame.caller) {
    137      frames.push(String(frame).padStart(4));
    138    }
    139    details.stack = frames.join("\n");
    140  }
    141 
    142  return details;
    143 }
    144 
    145 /**
    146 * Create a new FxAccountsWebChannel to listen for account updates
    147 *
    148 * @param {object} options Options
    149 *   @param {object} options
    150 *     @param {string} options.content_uri
    151 *     The FxA Content server uri
    152 *     @param {string} options.channel_id
    153 *     The ID of the WebChannel
    154 *     @param {string} options.helpers
    155 *     Helpers functions. Should only be passed in for testing.
    156 * @class
    157 */
    158 export function FxAccountsWebChannel(options) {
    159  if (!options) {
    160    throw new Error("Missing configuration options");
    161  }
    162  if (!options.content_uri) {
    163    throw new Error("Missing 'content_uri' option");
    164  }
    165  this._contentUri = options.content_uri;
    166 
    167  if (!options.channel_id) {
    168    throw new Error("Missing 'channel_id' option");
    169  }
    170  this._webChannelId = options.channel_id;
    171 
    172  // options.helpers is only specified by tests.
    173  ChromeUtils.defineLazyGetter(this, "_helpers", () => {
    174    return options.helpers || new FxAccountsWebChannelHelpers(options);
    175  });
    176 
    177  this._setupChannel();
    178 }
    179 
    180 FxAccountsWebChannel.prototype = {
    181  /**
    182   * WebChannel that is used to communicate with content page
    183   */
    184  _channel: null,
    185 
    186  /**
    187   * Helpers interface that does the heavy lifting.
    188   */
    189  _helpers: null,
    190 
    191  /**
    192   * WebChannel ID.
    193   */
    194  _webChannelId: null,
    195  /**
    196   * WebChannel origin, used to validate origin of messages
    197   */
    198  _webChannelOrigin: null,
    199 
    200  /**
    201   * The promise which is handling the most recent webchannel message we received.
    202   * Used to avoid us handling multiple messages concurrently.
    203   */
    204  _lastPromise: null,
    205 
    206  /**
    207   * Release all resources that are in use.
    208   */
    209  tearDown() {
    210    this._channel.stopListening();
    211    this._channel = null;
    212    this._channelCallback = null;
    213  },
    214 
    215  /**
    216   * Configures and registers a new WebChannel
    217   *
    218   * @private
    219   */
    220  _setupChannel() {
    221    // if this.contentUri is present but not a valid URI, then this will throw an error.
    222    try {
    223      this._webChannelOrigin = Services.io.newURI(this._contentUri);
    224      this._registerChannel();
    225    } catch (e) {
    226      log.error(e);
    227      throw e;
    228    }
    229  },
    230 
    231  _receiveMessage(message, sendingContext) {
    232    log.trace(`_receiveMessage for command ${message.command}`);
    233    let shouldCheckRemoteType =
    234      lazy.separatePrivilegedMozillaWebContentProcess &&
    235      lazy.separatedMozillaDomains.some(function (val) {
    236        return (
    237          lazy.accountServer.asciiHost == val ||
    238          lazy.accountServer.asciiHost.endsWith("." + val)
    239        );
    240      });
    241    let { currentRemoteType } = sendingContext.browsingContext;
    242    if (shouldCheckRemoteType && currentRemoteType != "privilegedmozilla") {
    243      log.error(
    244        `Rejected FxA webchannel message from remoteType = ${currentRemoteType}`
    245      );
    246      return;
    247    }
    248 
    249    // Here we do some promise dances to ensure we are never handling multiple messages
    250    // concurrently, which can happen for async message handlers.
    251    // Not all handlers are async, which is something we should clean up to make this simpler.
    252    // Start with ensuring the last promise we saw is complete.
    253    let lastPromise = this._lastPromise || Promise.resolve();
    254    this._lastPromise = lastPromise
    255      .then(() => {
    256        return this._promiseMessage(message, sendingContext);
    257      })
    258      .catch(e => {
    259        log.error("Handling webchannel message failed", e);
    260        this._sendError(e, message, sendingContext);
    261      })
    262      .finally(() => {
    263        this._lastPromise = null;
    264      });
    265  },
    266 
    267  async _promiseMessage(message, sendingContext) {
    268    const { command, data } = message;
    269    let browser = sendingContext.browsingContext.top.embedderElement;
    270    switch (command) {
    271      case COMMAND_PROFILE_CHANGE:
    272        Services.obs.notifyObservers(
    273          null,
    274          ON_PROFILE_CHANGE_NOTIFICATION,
    275          data.uid
    276        );
    277        break;
    278      case COMMAND_LOGIN:
    279        await this._helpers.login(data);
    280        await this._channel.send(
    281          { command, messageId: message.messageId, data: { ok: true } },
    282          sendingContext
    283        );
    284        break;
    285      case COMMAND_OAUTH:
    286        await this._helpers.oauthLogin(data);
    287        await this._channel.send(
    288          { command, messageId: message.messageId, data: { ok: true } },
    289          sendingContext
    290        );
    291        break;
    292      case COMMAND_LOGOUT:
    293      case COMMAND_DELETE:
    294        await this._helpers.logout(data.uid);
    295        await this._channel.send(
    296          { command, messageId: message.messageId, data: { ok: true } },
    297          sendingContext
    298        );
    299        break;
    300      case COMMAND_CAN_LINK_ACCOUNT:
    301        {
    302          let response = { command, messageId: message.messageId };
    303          // If browser profiles are not enabled, then we use the old merge sync dialog
    304          if (!this._helpers._selectableProfilesEnabled()) {
    305            response.data = { ok: this._helpers.shouldAllowRelink(data) };
    306            this._channel.send(response, sendingContext);
    307            break;
    308          }
    309          // In the new sync warning, we give users a few more options to
    310          // control what they want to do with their sync data
    311          let result =
    312            await this._helpers.promptProfileSyncWarningIfNeeded(data);
    313          switch (result.action) {
    314            case "create-profile":
    315              lazy.SelectableProfileService.createNewProfile();
    316              response.data = { ok: false };
    317              break;
    318            case "switch-profile":
    319              lazy.SelectableProfileService.launchInstance(result.data);
    320              response.data = { ok: false };
    321              break;
    322            // Either no warning was shown, or user selected the continue option
    323            // to link the account
    324            case "continue":
    325              response.data = { ok: true };
    326              break;
    327            case "cancel":
    328              response.data = { ok: false };
    329              break;
    330            default:
    331              log.error(
    332                "Invalid FxAccountsWebChannel dialog response: ",
    333                result.action
    334              );
    335              response.data = { ok: false };
    336              break;
    337          }
    338          log.debug("FxAccountsWebChannel response", response);
    339          // Send the response based on what the user selected above
    340          this._channel.send(response, sendingContext);
    341        }
    342        break;
    343      case COMMAND_SYNC_PREFERENCES:
    344        this._helpers.openSyncPreferences(browser, data.entryPoint);
    345        this._channel.send(
    346          { command, messageId: message.messageId, data: { ok: true } },
    347          sendingContext
    348        );
    349        break;
    350      case COMMAND_PAIR_PREFERENCES:
    351        if (lazy.pairingEnabled) {
    352          let win = browser.ownerGlobal;
    353          this._channel.send(
    354            { command, messageId: message.messageId, data: { ok: true } },
    355            sendingContext
    356          );
    357          win.openTrustedLinkIn(
    358            "about:preferences?action=pair#sync",
    359            "current"
    360          );
    361        }
    362        break;
    363      case COMMAND_FIREFOX_VIEW:
    364        this._helpers.openFirefoxView(browser, data.entryPoint);
    365        this._channel.send(
    366          { command, messageId: message.messageId, data: { ok: true } },
    367          sendingContext
    368        );
    369        break;
    370      case COMMAND_CHANGE_PASSWORD:
    371        await this._helpers.changePassword(data);
    372        await this._channel.send(
    373          { command, messageId: message.messageId, data: { ok: true } },
    374          sendingContext
    375        );
    376        break;
    377      case COMMAND_FXA_STATUS: {
    378        log.debug("fxa_status received");
    379        const service = data && data.service;
    380        const isPairing = data && data.isPairing;
    381        const context = data && data.context;
    382        await this._helpers
    383          .getFxaStatus(service, sendingContext, isPairing, context)
    384          .then(fxaStatus => {
    385            let response = {
    386              command,
    387              messageId: message.messageId,
    388              data: fxaStatus,
    389            };
    390            this._channel.send(response, sendingContext);
    391          });
    392        break;
    393      }
    394      case COMMAND_PAIR_HEARTBEAT:
    395      case COMMAND_PAIR_SUPP_METADATA:
    396      case COMMAND_PAIR_AUTHORIZE:
    397      case COMMAND_PAIR_DECLINE:
    398      case COMMAND_PAIR_COMPLETE: {
    399        log.debug(`Pairing command ${command} received`);
    400        const { channel_id: channelId } = data;
    401        delete data.channel_id;
    402        const flow = lazy.FxAccountsPairingFlow.get(channelId);
    403        if (!flow) {
    404          log.warn(`Could not find a pairing flow for ${channelId}`);
    405          return;
    406        }
    407        flow.onWebChannelMessage(command, data).then(replyData => {
    408          this._channel.send(
    409            {
    410              command,
    411              messageId: message.messageId,
    412              data: replyData,
    413            },
    414            sendingContext
    415          );
    416        });
    417        break;
    418      }
    419      default: {
    420        let errorMessage = "Unrecognized FxAccountsWebChannel command";
    421        log.warn(errorMessage, command);
    422        this._channel.send({
    423          command,
    424          messageId: message.messageId,
    425          data: { error: errorMessage },
    426        });
    427        // As a safety measure we also terminate any pending FxA pairing flow.
    428        lazy.FxAccountsPairingFlow.finalizeAll();
    429        break;
    430      }
    431    }
    432  },
    433 
    434  _sendError(error, incomingMessage, sendingContext) {
    435    log.error("Failed to handle FxAccountsWebChannel message", error);
    436    this._channel.send(
    437      {
    438        command: incomingMessage.command,
    439        messageId: incomingMessage.messageId,
    440        data: {
    441          error: getErrorDetails(error),
    442        },
    443      },
    444      sendingContext
    445    );
    446  },
    447 
    448  /**
    449   * Create a new channel with the WebChannelBroker, setup a callback listener
    450   *
    451   * @private
    452   */
    453  _registerChannel() {
    454    /**
    455     * Processes messages that are called back from the FxAccountsChannel
    456     *
    457     * @param webChannelId {String}
    458     *        Command webChannelId
    459     * @param message {Object}
    460     *        Command message
    461     * @param sendingContext {Object}
    462     *        Message sending context.
    463     *        @param sendingContext.browsingContext {BrowsingContext}
    464     *               The browsingcontext from which the
    465     *               WebChannelMessageToChrome was sent.
    466     *        @param sendingContext.eventTarget {EventTarget}
    467     *               The <EventTarget> where the message was sent.
    468     *        @param sendingContext.principal {Principal}
    469     *               The <Principal> of the EventTarget where the message was sent.
    470     * @private
    471     */
    472    let listener = (webChannelId, message, sendingContext) => {
    473      if (message) {
    474        log.debug("FxAccountsWebChannel message received", message.command);
    475        if (logPII()) {
    476          log.debug("FxAccountsWebChannel message details", message);
    477        }
    478        try {
    479          this._receiveMessage(message, sendingContext);
    480        } catch (error) {
    481          // this should be impossible - _receiveMessage will do this, but better safe than sorry.
    482          log.error(
    483            "Unexpected webchannel error escaped from promise error handlers"
    484          );
    485          this._sendError(error, message, sendingContext);
    486        }
    487      }
    488    };
    489 
    490    this._channelCallback = listener;
    491    this._channel = new lazy.WebChannel(
    492      this._webChannelId,
    493      this._webChannelOrigin
    494    );
    495    this._channel.listen(listener);
    496    log.debug(
    497      "FxAccountsWebChannel registered: " +
    498        this._webChannelId +
    499        " with origin " +
    500        this._webChannelOrigin.prePath
    501    );
    502  },
    503 };
    504 
    505 export function FxAccountsWebChannelHelpers(options) {
    506  options = options || {};
    507 
    508  this._fxAccounts = options.fxAccounts || lazy.fxAccounts;
    509  this._weaveXPCOM = options.weaveXPCOM || null;
    510  this._privateBrowsingUtils =
    511    options.privateBrowsingUtils || lazy.PrivateBrowsingUtils;
    512 }
    513 
    514 FxAccountsWebChannelHelpers.prototype = {
    515  // If the last fxa account used for sync isn't this account, we display
    516  // a modal dialog checking they really really want to do this...
    517  // (This is sync-specific, so ideally would be in sync's identity module,
    518  // but it's a little more seamless to do here, and sync is currently the
    519  // only fxa consumer, so...
    520  shouldAllowRelink(acctData) {
    521    return (
    522      !this._needRelinkWarning(acctData) ||
    523      this._promptForRelink(acctData.email)
    524    );
    525  },
    526 
    527  /**
    528   * Checks if the user is potentially hitting an issue with the current
    529   * account they're logging into. Returns the choice of the user if shown
    530   *
    531   * @returns {string} - The corresponding option the user pressed. Can be either:
    532   * cancel, continue, switch-profile, or create-profile
    533   */
    534  async promptProfileSyncWarningIfNeeded(acctData) {
    535    // Was a previous account signed into this profile or is there another profile currently signed in
    536    // to the account we're signing into
    537    let profileLinkedWithAcct = acctData.uid
    538      ? await this._getProfileAssociatedWithAcct(acctData.uid)
    539      : null;
    540    if (this._needRelinkWarning(acctData) || profileLinkedWithAcct) {
    541      return this._promptForProfileSyncWarning(
    542        acctData.email,
    543        profileLinkedWithAcct
    544      );
    545    }
    546    // The user has no warnings needed and can continue signing in
    547    return { action: "continue" };
    548  },
    549 
    550  async _initializeSync() {
    551    // A sync-specific hack - we want to ensure sync has been initialized
    552    // before we set the signed-in user.
    553    // XXX - probably not true any more, especially now we have observerPreloads
    554    // in FxAccounts.sys.mjs?
    555    let xps =
    556      this._weaveXPCOM ||
    557      Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports)
    558        .wrappedJSObject;
    559    await xps.whenLoaded();
    560    return xps;
    561  },
    562 
    563  _setEnabledEngines(offeredEngines, declinedEngines) {
    564    if (offeredEngines && declinedEngines) {
    565      log.debug("Received offered engines", offeredEngines);
    566      CHOOSE_WHAT_TO_SYNC_OPTIONALLY_AVAILABLE.forEach(engine => {
    567        if (
    568          offeredEngines.includes(engine) &&
    569          !declinedEngines.includes(engine)
    570        ) {
    571          // These extra engines are disabled by default.
    572          log.debug(`Enabling optional engine '${engine}'`);
    573          Services.prefs.setBoolPref(`services.sync.engine.${engine}`, true);
    574        }
    575      });
    576      log.debug("Received declined engines", declinedEngines);
    577      lazy.Weave.Service.engineManager.setDeclined(declinedEngines);
    578      declinedEngines.forEach(engine => {
    579        Services.prefs.setBoolPref(`services.sync.engine.${engine}`, false);
    580      });
    581    } else {
    582      log.debug("Did not receive any engine selection information");
    583    }
    584  },
    585 
    586  /**
    587   * Internal function used to configure the requested services.
    588   *
    589   * The "services" param is an object as received from the FxA server.
    590   */
    591  async _enableRequestedServices(requestedServices) {
    592    if (!requestedServices) {
    593      log.warn(
    594        "fxa login completed but we don't have a record of which services were enabled."
    595      );
    596      return;
    597    }
    598    log.debug(`services requested are ${Object.keys(requestedServices)}`);
    599    if (requestedServices.sync) {
    600      const xps = await this._initializeSync();
    601      const { offeredEngines, declinedEngines } = requestedServices.sync;
    602      this._setEnabledEngines(offeredEngines, declinedEngines);
    603      log.debug("Webchannel is enabling sync");
    604      await xps.Weave.Service.configure();
    605    }
    606  },
    607 
    608  /**
    609   * The login message is sent when the user user has initially logged in but may not be fully connected.
    610   * * In the non-oauth flows, if the user is verified, then the browser itself is able to transition the
    611   *   user to fully connected.
    612   * * In the oauth flows, we will need an `oauth_login` message with our scoped keys to be fully connected.
    613   *
    614   * @param accountData the user's account data and credentials
    615   */
    616  async login(accountData) {
    617    // This is delicate for oauth flows and edge-cases. Consider (a) user logs in but does not verify,
    618    // (b) browser restarts, (c) user select "finish setup", at which point they are again prompted for their password.
    619    // In that scenario, we've been sent this `login` message *both* at (a) and at (c).
    620    // Importantly, the message from (a) is the one that actually has the service information we care about
    621    // (eg, the sync engine selections) - (c) *will* have `services.sync` but it will be an empty object.
    622    // This means we need to take care to not lose the services from (a) when processing (c).
    623    const signedInUser = await this._fxAccounts.getSignedInUser([
    624      "requestedServices",
    625    ]);
    626    let existingServices;
    627    if (signedInUser) {
    628      if (signedInUser.uid != accountData.uid) {
    629        log.warn(
    630          "the webchannel found a different user signed in - signing them out."
    631        );
    632        await this._disconnect();
    633      } else {
    634        existingServices = signedInUser.requestedServices
    635          ? JSON.parse(signedInUser.requestedServices)
    636          : {};
    637        log.debug(
    638          "Webchannel is updating the info for an already logged in user."
    639        );
    640      }
    641    } else {
    642      log.debug("Webchannel is logging new a user in.");
    643    }
    644    // Stuff we never want to keep after being logged in (and no longer get in 2026)
    645    delete accountData.customizeSync;
    646    delete accountData.verifiedCanLinkAccount;
    647    delete accountData.keyFetchToken;
    648    delete accountData.unwrapBKey;
    649 
    650    // The "services" being connected - see above re our careful handling of existing data.
    651    // Note that we don't attempt to merge any data - we keep the first value we see for a service
    652    // and ignore that service subsequently (as it will be common for subsequent messages to
    653    // name a service but not supply any data for it)
    654    const requestedServices = {
    655      ...(accountData.services ?? {}),
    656      ...existingServices,
    657    };
    658    await this._fxAccounts.telemetry.recordConnection(
    659      Object.keys(requestedServices),
    660      "webchannel"
    661    );
    662    delete accountData.services;
    663    // We need to remember the requested services because we can't act on them until we get the `oauth_login` message.
    664    // And because we might not get that message in this browser session (eg, the browser might restart before the
    665    // user enters their verification code), they are persisted with the account state.
    666    log.debug(`storing info for services ${Object.keys(requestedServices)}`);
    667    accountData.requestedServices = JSON.stringify(requestedServices);
    668 
    669    this.setPreviousAccountHashPref(accountData.uid);
    670 
    671    // For scenarios like user is logged in via third-party but wants
    672    // to enable sync (password) the server will send an additional login command
    673    // we need to ensure we don't destroy the existing session
    674    if (signedInUser && signedInUser.uid === accountData.uid) {
    675      await this._fxAccounts._internal.updateUserAccountData(accountData);
    676      log.debug("Webchannel finished updating already logged in user.");
    677    } else {
    678      await this._fxAccounts._internal.setSignedInUser(accountData);
    679      log.debug("Webchannel finished logging a user in.");
    680    }
    681  },
    682 
    683  /**
    684   * Logs in to sync by completing an OAuth flow
    685   *
    686   * @param {object} oauthData: The oauth code and state as returned by the server
    687   */
    688  async oauthLogin(oauthData) {
    689    log.debug("Webchannel is completing the oauth flow");
    690    const { uid, sessionToken, requestedServices } =
    691      await this._fxAccounts._internal.getUserAccountData([
    692        "uid",
    693        "sessionToken",
    694        "requestedServices",
    695      ]);
    696    // First we finish the ongoing oauth flow
    697    const { scopedKeys, refreshToken } =
    698      await this._fxAccounts._internal.completeOAuthFlow(
    699        sessionToken,
    700        oauthData.code,
    701        oauthData.state
    702      );
    703 
    704    // We don't currently use the refresh token in Firefox Desktop, lets be good citizens and revoke it.
    705    await this._fxAccounts._internal.destroyOAuthToken({ token: refreshToken });
    706 
    707    // Remember the account for future merge warnings etc.
    708    this.setPreviousAccountHashPref(uid);
    709 
    710    if (!scopedKeys) {
    711      log.info(
    712        "OAuth login completed without scoped keys; skipping Sync key storage"
    713      );
    714    } else {
    715      // Then, we persist the sync keys
    716      await this._fxAccounts._internal.setScopedKeys(scopedKeys);
    717    }
    718 
    719    try {
    720      let parsedRequestedServices;
    721      if (requestedServices) {
    722        parsedRequestedServices = JSON.parse(requestedServices);
    723      }
    724      await this._enableRequestedServices(parsedRequestedServices);
    725    } finally {
    726      // We don't want them hanging around in storage.
    727      await this._fxAccounts._internal.updateUserAccountData({
    728        uid,
    729        requestedServices: null,
    730      });
    731    }
    732 
    733    // Now that we have the scoped keys, we set our status to verified.
    734    // This will kick off Sync or other services we configured.
    735    await this._fxAccounts._internal.setUserVerified();
    736    log.debug("Webchannel completed oauth flows");
    737  },
    738 
    739  /**
    740   * Disconnects the user from Sync and FxA
    741   */
    742  _disconnect() {
    743    return SyncDisconnect.disconnect(false);
    744  },
    745 
    746  /**
    747   * logout the fxaccounts service
    748   *
    749   * @param the uid of the account which have been logged out
    750   */
    751  async logout(uid) {
    752    let fxa = this._fxAccounts;
    753    let userData = await fxa._internal.getUserAccountData(["uid"]);
    754    if (userData && userData.uid === uid) {
    755      await fxa.telemetry.recordDisconnection(null, "webchannel");
    756      // true argument is `localOnly`, because server-side stuff
    757      // has already been taken care of by the content server
    758      await fxa.signOut(true);
    759    }
    760  },
    761 
    762  /**
    763   * Check if `sendingContext` is in private browsing mode.
    764   */
    765  isPrivateBrowsingMode(sendingContext) {
    766    if (!sendingContext) {
    767      log.error(
    768        "Unable to check for private browsing mode (no sending context), assuming true"
    769      );
    770      return true;
    771    }
    772 
    773    let browser = sendingContext.browsingContext.top.embedderElement;
    774    if (!browser) {
    775      log.error(
    776        "Unable to check for private browsing mode (no browser), assuming true"
    777      );
    778      return true;
    779    }
    780    const isPrivateBrowsing =
    781      this._privateBrowsingUtils.isBrowserPrivate(browser);
    782    return isPrivateBrowsing;
    783  },
    784 
    785  /**
    786   * Check whether sending fxa_status data should be allowed.
    787   */
    788  shouldAllowFxaStatus(service, sendingContext, isPairing, context) {
    789    // Return user data for any service in non-PB mode. In PB mode,
    790    // only return user data if service==="sync" or is in pairing mode
    791    // (as service will be equal to the OAuth client ID and not "sync").
    792    //
    793    // This behaviour allows users to click the "Manage Account"
    794    // link from about:preferences#sync while in PB mode and things
    795    // "just work". While in non-PB mode, users can sign into
    796    // Pocket w/o entering their password a 2nd time, while in PB
    797    // mode they *will* have to enter their email/password again.
    798    //
    799    // The difference in behaviour is to try to match user
    800    // expectations as to what is and what isn't part of the browser.
    801    // Sync is viewed as an integral part of the browser, interacting
    802    // with FxA as part of a Sync flow should work all the time. If
    803    // Sync is broken in PB mode, users will think Firefox is broken.
    804    // See https://bugzilla.mozilla.org/show_bug.cgi?id=1323853
    805    let pb = this.isPrivateBrowsingMode(sendingContext);
    806    let ok = !pb || service === "sync" || isPairing;
    807    log.debug(
    808      `fxa status ok=${ok} - private=${pb}, service=${service}, context=${context}, pairing=${isPairing}`
    809    );
    810    return ok;
    811  },
    812 
    813  /**
    814   * Get fxa_status information. Resolves to { signedInUser: <user_data> }.
    815   * If returning status information is not allowed or no user is signed into
    816   * Sync, `user_data` will be null.
    817   */
    818  async getFxaStatus(service, sendingContext, isPairing, context) {
    819    let signedInUser = null;
    820 
    821    if (
    822      this.shouldAllowFxaStatus(service, sendingContext, isPairing, context)
    823    ) {
    824      const userData = await this._fxAccounts._internal.getUserAccountData([
    825        "email",
    826        "sessionToken",
    827        "uid",
    828        "verified",
    829      ]);
    830      if (userData) {
    831        signedInUser = {
    832          email: userData.email,
    833          sessionToken: userData.sessionToken,
    834          uid: userData.uid,
    835          verified: userData.verified,
    836        };
    837      }
    838    }
    839 
    840    const capabilities = this._getCapabilities();
    841 
    842    return {
    843      signedInUser,
    844      clientId: OAUTH_CLIENT_ID,
    845      capabilities,
    846    };
    847  },
    848 
    849  _getCapabilities() {
    850    let engines = Array.from(CHOOSE_WHAT_TO_SYNC_ALWAYS_AVAILABLE);
    851    for (let optionalEngine of CHOOSE_WHAT_TO_SYNC_OPTIONALLY_AVAILABLE) {
    852      if (
    853        Services.prefs.getBoolPref(
    854          `services.sync.engine.${optionalEngine}.available`,
    855          false
    856        )
    857      ) {
    858        engines.push(optionalEngine);
    859      }
    860    }
    861    return {
    862      multiService: true,
    863      pairing: lazy.pairingEnabled,
    864      choose_what_to_sync: true,
    865      // This capability is for telling FxA that the current build can accept
    866      // accounts without passwords/sync keys (third-party auth)
    867      keys_optional: true,
    868      can_link_account_uid: true,
    869      engines,
    870    };
    871  },
    872 
    873  async changePassword(credentials) {
    874    // If |credentials| has fields that aren't handled by accounts storage,
    875    // updateUserAccountData will throw - mainly to prevent errors in code
    876    // that hard-codes field names.
    877    // However, in this case the field names aren't really in our control.
    878    // We *could* still insist the server know what fields names are valid,
    879    // but that makes life difficult for the server when Firefox adds new
    880    // features (ie, new fields) - forcing the server to track a map of
    881    // versions to supported field names doesn't buy us much.
    882    // So we just remove field names we know aren't handled.
    883    let newCredentials = {
    884      device: null, // Force a brand new device registration.
    885      // We force the re-encryption of the send tab keys using the new sync key after the password change
    886      encryptedSendTabKeys: null,
    887    };
    888    for (let name of Object.keys(credentials)) {
    889      if (
    890        name == "email" ||
    891        name == "uid" ||
    892        lazy.FxAccountsStorageManagerCanStoreField(name)
    893      ) {
    894        newCredentials[name] = credentials[name];
    895      } else {
    896        log.info("changePassword ignoring unsupported field", name);
    897      }
    898    }
    899    await this._fxAccounts._internal.updateUserAccountData(newCredentials);
    900    await this._fxAccounts._internal.updateDeviceRegistration();
    901  },
    902 
    903  /**
    904   * Remember that a particular account id was previously signed in to this device.
    905   *
    906   * @param uid the account uid
    907   */
    908  setPreviousAccountHashPref(uid) {
    909    if (!uid) {
    910      throw new Error("No uid specified");
    911    }
    912    Services.prefs.setStringPref(
    913      PREF_LAST_FXA_USER_UID,
    914      lazy.CryptoUtils.sha256Base64(uid)
    915    );
    916    // This should not be necessary but exists just to be safe, to avoid
    917    // any possibility we somehow end up with *both* prefs set and each indicating
    918    // a different account.
    919    Services.prefs.clearUserPref(PREF_LAST_FXA_USER_EMAIL);
    920  },
    921 
    922  /**
    923   * Open Sync Preferences in the current tab of the browser
    924   *
    925   * @param {object} browser the browser in which to open preferences
    926   * @param {string} [entryPoint] entryPoint to use for logging
    927   */
    928  openSyncPreferences(browser, entryPoint) {
    929    let uri = "about:preferences";
    930    if (entryPoint) {
    931      uri += "?entrypoint=" + encodeURIComponent(entryPoint);
    932    }
    933    uri += "#sync";
    934 
    935    browser.loadURI(Services.io.newURI(uri), {
    936      triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
    937    });
    938  },
    939 
    940  /**
    941   * Open Firefox View in the browser's window
    942   *
    943   * @param {object} browser the browser in whose window we'll open Firefox View
    944   */
    945  openFirefoxView(browser) {
    946    browser.ownerGlobal.FirefoxViewHandler.openTab("syncedtabs");
    947  },
    948 
    949  /**
    950   * If a user signs in using a different account, the data from the
    951   * previous account and the new account will be merged. Ask the user
    952   * if they want to continue.
    953   *
    954   * @private
    955   */
    956  _needRelinkWarning(acctData) {
    957    // This code *never* expects both PREF_LAST_FXA_USER_EMAIL and PREF_LAST_FXA_USER_UID.
    958    // * If we have PREF_LAST_FXA_USER_EMAIL it means we were signed out before we migrated
    959    //   to UID, and can't learn that UID, so have no UID pref set.
    960    // * If the UID pref exists, our code since that landed will never write to the
    961    //   PREF_LAST_FXA_USER_EMAIL pref.
    962    // The only way both could be true would be something catastrophic, such as our
    963    // "migrate to uid at sign-out" code somehow died between writing the UID and
    964    // clearing the email.
    965    //
    966    // Therefore, we don't even try to handle both being set, but do prefer the UID
    967    // because that must have been written by the new code paths introduced for that pref.
    968    const lastUid = Services.prefs.getStringPref(PREF_LAST_FXA_USER_UID, "");
    969    if (lastUid) {
    970      // A special case here is for when no uid is specified by the server - that means the
    971      // server is about to create a new account. Therefore, the new account can't possibly
    972      // match.
    973      return (
    974        !acctData.uid || lastUid != lazy.CryptoUtils.sha256Base64(acctData.uid)
    975      );
    976    }
    977 
    978    // no uid pref, check if there's an EMAIL pref (which means a user previously signed out
    979    // before we landed this uid-aware code, so only know their email.)
    980    const lastEmail = Services.prefs.getStringPref(
    981      PREF_LAST_FXA_USER_EMAIL,
    982      ""
    983    );
    984    return (
    985      lastEmail && lastEmail != lazy.CryptoUtils.sha256Base64(acctData.email)
    986    );
    987  },
    988 
    989  // Does this install have multiple profiles available? The SelectableProfileService
    990  // being enabled isn't enough, because this doesn't tell us whether a new profile
    991  // as actually created!
    992  _selectableProfilesEnabled() {
    993    return (
    994      lazy.SelectableProfileService?.isEnabled &&
    995      lazy.SelectableProfileService?.hasCreatedSelectableProfiles()
    996    );
    997  },
    998 
    999  // Get the current name of the profile the user is currently on
   1000  _getCurrentProfileName() {
   1001    return lazy.SelectableProfileService?.currentProfile?.name;
   1002  },
   1003 
   1004  async _getAllProfiles() {
   1005    return await lazy.SelectableProfileService.getAllProfiles();
   1006  },
   1007 
   1008  /**
   1009   * Checks if a profile is associated with the given account email.
   1010   *
   1011   * @param {string} acctUid - The uid of the account to check.
   1012   * @returns {Promise<SelectableProfile|null>} - The profile associated with the account, or null if none.
   1013   */
   1014  async _getProfileAssociatedWithAcct(acctUid) {
   1015    let profiles = await this._getAllProfiles();
   1016    let currentProfileName = await this._getCurrentProfileName();
   1017    for (let profile of profiles) {
   1018      if (profile.name === currentProfileName) {
   1019        continue; // Skip current profile
   1020      }
   1021 
   1022      let profilePath = profile.path;
   1023      let signedInUserPath = PathUtils.join(profilePath, "signedInUser.json");
   1024      let signedInUser = await this._readJSONFileAsync(signedInUserPath);
   1025      if (
   1026        signedInUser?.accountData &&
   1027        signedInUser.accountData.uid === acctUid
   1028      ) {
   1029        // The account is signed into another profile
   1030        return profile;
   1031      }
   1032    }
   1033    return null;
   1034  },
   1035 
   1036  async _readJSONFileAsync(filePath) {
   1037    try {
   1038      let data = await IOUtils.readJSON(filePath);
   1039      if (data && data.version !== 1) {
   1040        throw new Error(
   1041          `Unsupported signedInUser.json version: ${data.version}`
   1042        );
   1043      }
   1044      return data;
   1045    } catch (e) {
   1046      // File not found or error reading/parsing
   1047      return null;
   1048    }
   1049  },
   1050 
   1051  /**
   1052   * Show the user a warning dialog that the data from the previous account
   1053   * and the new account will be merged. _promptForSyncWarning should be
   1054   * used instead of this
   1055   *
   1056   * @private
   1057   */
   1058  _promptForRelink(acctEmail) {
   1059    let [continueLabel, title, heading, description] =
   1060      lazy.l10n.formatValuesSync([
   1061        { id: "sync-setup-verify-continue" },
   1062        { id: "sync-setup-verify-title" },
   1063        { id: "sync-setup-verify-heading" },
   1064        {
   1065          id: "sync-setup-verify-description",
   1066          args: {
   1067            email: acctEmail,
   1068          },
   1069        },
   1070      ]);
   1071    let body = heading + "\n\n" + description;
   1072    let ps = Services.prompt;
   1073    let buttonFlags =
   1074      ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING +
   1075      ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL +
   1076      ps.BUTTON_POS_1_DEFAULT;
   1077 
   1078    // If running in context of the browser chrome, window does not exist.
   1079    let pressed = Services.prompt.confirmEx(
   1080      null,
   1081      title,
   1082      body,
   1083      buttonFlags,
   1084      continueLabel,
   1085      null,
   1086      null,
   1087      null,
   1088      {}
   1089    );
   1090    this.emitSyncWarningDialogTelemetry(
   1091      { 0: "continue", 1: "cancel" },
   1092      pressed,
   1093      false // old dialog doesn't have other profiles
   1094    );
   1095    return pressed === 0; // 0 is the "continue" button
   1096  },
   1097 
   1098  /**
   1099   * Similar to _promptForRelink but more offers more contextual warnings
   1100   * to the user to support browser profiles.
   1101   *
   1102   * @returns {string} - The corresponding option the user pressed. Can be either:
   1103   * cancel, continue, switch-profile, or create-profile
   1104   */
   1105  _promptForProfileSyncWarning(acctEmail, profileLinkedWithAcct) {
   1106    let currentProfile = this._getCurrentProfileName();
   1107    let title, heading, description, mergeLabel, switchLabel;
   1108    if (profileLinkedWithAcct) {
   1109      [title, heading, description, mergeLabel, switchLabel] =
   1110        lazy.l10n.formatValuesSync([
   1111          { id: "sync-account-in-use-header" },
   1112          {
   1113            id: lazy.allowSyncMerge
   1114              ? "sync-account-already-signed-in-header"
   1115              : "sync-account-in-use-header-merge",
   1116            args: {
   1117              acctEmail,
   1118              otherProfile: profileLinkedWithAcct.name,
   1119            },
   1120          },
   1121          {
   1122            id: lazy.allowSyncMerge
   1123              ? "sync-account-in-use-description-merge"
   1124              : "sync-account-in-use-description",
   1125            args: {
   1126              acctEmail,
   1127              currentProfile,
   1128              otherProfile: profileLinkedWithAcct.name,
   1129            },
   1130          },
   1131          {
   1132            id: "sync-button-sync-profile",
   1133            args: { profileName: currentProfile },
   1134          },
   1135          {
   1136            id: "sync-button-switch-profile",
   1137            args: { profileName: profileLinkedWithAcct.name },
   1138          },
   1139        ]);
   1140    } else {
   1141      // This current profile was previously associated with a different account
   1142      [title, heading, description, mergeLabel, switchLabel] =
   1143        lazy.l10n.formatValuesSync([
   1144          {
   1145            id: lazy.allowSyncMerge
   1146              ? "sync-profile-different-account-title-merge"
   1147              : "sync-profile-different-account-title",
   1148          },
   1149          {
   1150            id: "sync-profile-different-account-header",
   1151          },
   1152          {
   1153            id: lazy.allowSyncMerge
   1154              ? "sync-profile-different-account-description-merge"
   1155              : "sync-profile-different-account-description",
   1156            args: {
   1157              acctEmail,
   1158              profileName: currentProfile,
   1159            },
   1160          },
   1161          { id: "sync-button-sync-and-merge" },
   1162          { id: "sync-button-create-profile" },
   1163        ]);
   1164    }
   1165    let result = this.showWarningPrompt({
   1166      title,
   1167      body: `${heading}\n\n${description}`,
   1168      btnLabel1: lazy.allowSyncMerge ? mergeLabel : switchLabel,
   1169      btnLabel2: lazy.allowSyncMerge ? switchLabel : null,
   1170      isAccountLoggedIntoAnotherProfile: !!profileLinkedWithAcct,
   1171    });
   1172 
   1173    // If the user chose to switch profiles, return the associated profile as well.
   1174    if (result === "switch-profile") {
   1175      return { action: result, data: profileLinkedWithAcct };
   1176    }
   1177 
   1178    // For all other actions, just return the action name.
   1179    return { action: result };
   1180  },
   1181 
   1182  /**
   1183   * Shows the user a warning prompt.
   1184   *
   1185   * @returns {string} - The corresponding option the user pressed. Can be either:
   1186   * cancel, continue, switch-profile, or create-profile
   1187   */
   1188  showWarningPrompt({
   1189    title,
   1190    body,
   1191    btnLabel1,
   1192    btnLabel2,
   1193    isAccountLoggedIntoAnotherProfile,
   1194  }) {
   1195    let ps = Services.prompt;
   1196    let buttonFlags;
   1197    let pressed;
   1198    let actionMap = {};
   1199 
   1200    if (lazy.allowSyncMerge) {
   1201      // Merge allowed: two options + cancel
   1202      buttonFlags =
   1203        ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING +
   1204        ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING +
   1205        ps.BUTTON_POS_2 * ps.BUTTON_TITLE_CANCEL +
   1206        ps.BUTTON_POS_2_DEFAULT;
   1207 
   1208      // Define action map based on context
   1209      if (isAccountLoggedIntoAnotherProfile) {
   1210        // Account is associated with another profile
   1211        actionMap = {
   1212          0: "continue", // merge option
   1213          1: "switch-profile",
   1214          2: "cancel",
   1215        };
   1216      } else {
   1217        // Profile was previously logged in with another account
   1218        actionMap = {
   1219          0: "continue", // merge option
   1220          1: "create-profile",
   1221          2: "cancel",
   1222        };
   1223      }
   1224 
   1225      // Show the prompt
   1226      pressed = ps.confirmEx(
   1227        null,
   1228        title,
   1229        body,
   1230        buttonFlags,
   1231        btnLabel1,
   1232        btnLabel2,
   1233        null,
   1234        null,
   1235        {}
   1236      );
   1237    } else {
   1238      // Merge not allowed: one option + cancel
   1239      buttonFlags =
   1240        ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING +
   1241        ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL +
   1242        ps.BUTTON_POS_1_DEFAULT;
   1243 
   1244      // Define action map based on context
   1245      if (isAccountLoggedIntoAnotherProfile) {
   1246        // Account is associated with another profile
   1247        actionMap = {
   1248          0: "switch-profile",
   1249          1: "cancel",
   1250        };
   1251      } else {
   1252        // Profile was previously logged in with another account
   1253        actionMap = {
   1254          0: "create-profile",
   1255          1: "cancel",
   1256        };
   1257      }
   1258 
   1259      // Show the prompt
   1260      pressed = ps.confirmEx(
   1261        null,
   1262        title,
   1263        body,
   1264        buttonFlags,
   1265        btnLabel1,
   1266        null,
   1267        null,
   1268        null,
   1269        {}
   1270      );
   1271    }
   1272 
   1273    this.emitSyncWarningDialogTelemetry(
   1274      actionMap,
   1275      pressed,
   1276      isAccountLoggedIntoAnotherProfile
   1277    );
   1278    return actionMap[pressed] || "unknown";
   1279  },
   1280 
   1281  emitSyncWarningDialogTelemetry(
   1282    actionMap,
   1283    pressed,
   1284    isAccountLoggedIntoAnotherProfile
   1285  ) {
   1286    let variant;
   1287 
   1288    if (!this._selectableProfilesEnabled()) {
   1289      // Old merge dialog
   1290      variant = "old-merge";
   1291    } else if (isAccountLoggedIntoAnotherProfile) {
   1292      // Sync warning dialog for profile already associated
   1293      variant = lazy.allowSyncMerge
   1294        ? "sync-warning-allow-merge"
   1295        : "sync-warning";
   1296    } else {
   1297      // Sync warning dialog for a different account previously logged in
   1298      variant = lazy.allowSyncMerge
   1299        ? "merge-warning-allow-merge"
   1300        : "merge-warning";
   1301    }
   1302 
   1303    // Telemetry extra options
   1304    let extraOptions = {
   1305      variant_shown: variant,
   1306      option_clicked: actionMap[pressed] || "unknown",
   1307    };
   1308 
   1309    // Record telemetry
   1310    Glean.syncMergeDialog?.clicked?.record(extraOptions);
   1311  },
   1312 };
   1313 
   1314 var singleton;
   1315 
   1316 // The entry-point for this module, which ensures only one of our channels is
   1317 // ever created - we require this because the WebChannel is global in scope
   1318 // (eg, it uses the observer service to tell interested parties of interesting
   1319 // things) and allowing multiple channels would cause such notifications to be
   1320 // sent multiple times.
   1321 export var EnsureFxAccountsWebChannel = () => {
   1322  let contentUri = Services.urlFormatter.formatURLPref(
   1323    "identity.fxaccounts.remote.root"
   1324  );
   1325  if (singleton && singleton._contentUri !== contentUri) {
   1326    singleton.tearDown();
   1327    singleton = null;
   1328  }
   1329  if (!singleton) {
   1330    try {
   1331      if (contentUri) {
   1332        // The FxAccountsWebChannel listens for events and updates
   1333        // the state machine accordingly.
   1334        singleton = new FxAccountsWebChannel({
   1335          content_uri: contentUri,
   1336          channel_id: WEBCHANNEL_ID,
   1337        });
   1338      } else {
   1339        log.warn("FxA WebChannel functionaly is disabled due to no URI pref.");
   1340      }
   1341    } catch (ex) {
   1342      log.error("Failed to create FxA WebChannel", ex);
   1343    }
   1344  }
   1345 };