Bug 1859954 - Use XP_DARWIN rather than XP_MACOS in PHC r=glandium
[gecko.git] / services / fxaccounts / FxAccountsWebChannel.sys.mjs
blob8d12c5136b273df596be4d41cdfde9fe7fa2a93f
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/. */
5 /**
6  * Firefox Accounts Web Channel.
7  *
8  * Uses the WebChannel component to receive messages
9  * about account state changes.
10  */
12 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
14 import {
15   COMMAND_PROFILE_CHANGE,
16   COMMAND_LOGIN,
17   COMMAND_LOGOUT,
18   COMMAND_DELETE,
19   COMMAND_CAN_LINK_ACCOUNT,
20   COMMAND_SYNC_PREFERENCES,
21   COMMAND_CHANGE_PASSWORD,
22   COMMAND_FXA_STATUS,
23   COMMAND_PAIR_HEARTBEAT,
24   COMMAND_PAIR_SUPP_METADATA,
25   COMMAND_PAIR_AUTHORIZE,
26   COMMAND_PAIR_DECLINE,
27   COMMAND_PAIR_COMPLETE,
28   COMMAND_PAIR_PREFERENCES,
29   COMMAND_FIREFOX_VIEW,
30   FX_OAUTH_CLIENT_ID,
31   ON_PROFILE_CHANGE_NOTIFICATION,
32   PREF_LAST_FXA_USER,
33   WEBCHANNEL_ID,
34   log,
35   logPII,
36 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
38 const lazy = {};
40 ChromeUtils.defineESModuleGetters(lazy, {
41   CryptoUtils: "resource://services-crypto/utils.sys.mjs",
42   FxAccountsPairingFlow: "resource://gre/modules/FxAccountsPairing.sys.mjs",
43   FxAccountsStorageManagerCanStoreField:
44     "resource://gre/modules/FxAccountsStorage.sys.mjs",
45   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
46   Weave: "resource://services-sync/main.sys.mjs",
47   WebChannel: "resource://gre/modules/WebChannel.sys.mjs",
48 });
49 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
50   return ChromeUtils.importESModule(
51     "resource://gre/modules/FxAccounts.sys.mjs"
52   ).getFxAccountsSingleton();
53 });
54 XPCOMUtils.defineLazyPreferenceGetter(
55   lazy,
56   "pairingEnabled",
57   "identity.fxaccounts.pairing.enabled"
59 XPCOMUtils.defineLazyPreferenceGetter(
60   lazy,
61   "separatePrivilegedMozillaWebContentProcess",
62   "browser.tabs.remote.separatePrivilegedMozillaWebContentProcess",
63   false
65 XPCOMUtils.defineLazyPreferenceGetter(
66   lazy,
67   "separatedMozillaDomains",
68   "browser.tabs.remote.separatedMozillaDomains",
69   "",
70   false,
71   val => val.split(",")
73 XPCOMUtils.defineLazyPreferenceGetter(
74   lazy,
75   "accountServer",
76   "identity.fxaccounts.remote.root",
77   null,
78   false,
79   val => Services.io.newURI(val)
82 // These engines were added years after Sync had been introduced, they need
83 // special handling since they are system add-ons and are un-available on
84 // older versions of Firefox.
85 const EXTRA_ENGINES = ["addresses", "creditcards"];
87 /**
88  * A helper function that extracts the message and stack from an error object.
89  * Returns a `{ message, stack }` tuple. `stack` will be null if the error
90  * doesn't have a stack trace.
91  */
92 function getErrorDetails(error) {
93   // Replace anything that looks like it might be a filepath on Windows or Unix
94   let cleanMessage = String(error)
95     .replace(/\\.*\\/gm, "[REDACTED]")
96     .replace(/\/.*\//gm, "[REDACTED]");
97   let details = { message: cleanMessage, stack: null };
99   // Adapted from Console.sys.mjs.
100   if (error.stack) {
101     let frames = [];
102     for (let frame = error.stack; frame; frame = frame.caller) {
103       frames.push(String(frame).padStart(4));
104     }
105     details.stack = frames.join("\n");
106   }
108   return details;
112  * Create a new FxAccountsWebChannel to listen for account updates
114  * @param {Object} options Options
115  *   @param {Object} options
116  *     @param {String} options.content_uri
117  *     The FxA Content server uri
118  *     @param {String} options.channel_id
119  *     The ID of the WebChannel
120  *     @param {String} options.helpers
121  *     Helpers functions. Should only be passed in for testing.
122  * @constructor
123  */
124 export function FxAccountsWebChannel(options) {
125   if (!options) {
126     throw new Error("Missing configuration options");
127   }
128   if (!options.content_uri) {
129     throw new Error("Missing 'content_uri' option");
130   }
131   this._contentUri = options.content_uri;
133   if (!options.channel_id) {
134     throw new Error("Missing 'channel_id' option");
135   }
136   this._webChannelId = options.channel_id;
138   // options.helpers is only specified by tests.
139   ChromeUtils.defineLazyGetter(this, "_helpers", () => {
140     return options.helpers || new FxAccountsWebChannelHelpers(options);
141   });
143   this._setupChannel();
146 FxAccountsWebChannel.prototype = {
147   /**
148    * WebChannel that is used to communicate with content page
149    */
150   _channel: null,
152   /**
153    * Helpers interface that does the heavy lifting.
154    */
155   _helpers: null,
157   /**
158    * WebChannel ID.
159    */
160   _webChannelId: null,
161   /**
162    * WebChannel origin, used to validate origin of messages
163    */
164   _webChannelOrigin: null,
166   /**
167    * Release all resources that are in use.
168    */
169   tearDown() {
170     this._channel.stopListening();
171     this._channel = null;
172     this._channelCallback = null;
173   },
175   /**
176    * Configures and registers a new WebChannel
177    *
178    * @private
179    */
180   _setupChannel() {
181     // if this.contentUri is present but not a valid URI, then this will throw an error.
182     try {
183       this._webChannelOrigin = Services.io.newURI(this._contentUri);
184       this._registerChannel();
185     } catch (e) {
186       log.error(e);
187       throw e;
188     }
189   },
191   _receiveMessage(message, sendingContext) {
192     const { command, data } = message;
193     let shouldCheckRemoteType =
194       lazy.separatePrivilegedMozillaWebContentProcess &&
195       lazy.separatedMozillaDomains.some(function (val) {
196         return (
197           lazy.accountServer.asciiHost == val ||
198           lazy.accountServer.asciiHost.endsWith("." + val)
199         );
200       });
201     let { currentRemoteType } = sendingContext.browsingContext;
202     if (shouldCheckRemoteType && currentRemoteType != "privilegedmozilla") {
203       log.error(
204         `Rejected FxA webchannel message from remoteType = ${currentRemoteType}`
205       );
206       return;
207     }
209     let browser = sendingContext.browsingContext.top.embedderElement;
210     switch (command) {
211       case COMMAND_PROFILE_CHANGE:
212         Services.obs.notifyObservers(
213           null,
214           ON_PROFILE_CHANGE_NOTIFICATION,
215           data.uid
216         );
217         break;
218       case COMMAND_LOGIN:
219         this._helpers
220           .login(data)
221           .catch(error => this._sendError(error, message, sendingContext));
222         break;
223       case COMMAND_LOGOUT:
224       case COMMAND_DELETE:
225         this._helpers
226           .logout(data.uid)
227           .catch(error => this._sendError(error, message, sendingContext));
228         break;
229       case COMMAND_CAN_LINK_ACCOUNT:
230         let canLinkAccount = this._helpers.shouldAllowRelink(data.email);
232         let response = {
233           command,
234           messageId: message.messageId,
235           data: { ok: canLinkAccount },
236         };
238         log.debug("FxAccountsWebChannel response", response);
239         this._channel.send(response, sendingContext);
240         break;
241       case COMMAND_SYNC_PREFERENCES:
242         this._helpers.openSyncPreferences(browser, data.entryPoint);
243         break;
244       case COMMAND_PAIR_PREFERENCES:
245         if (lazy.pairingEnabled) {
246           let window = browser.ownerGlobal;
247           // We should close the FxA tab after we open our pref page
248           let selectedTab = window.gBrowser.selectedTab;
249           window.switchToTabHavingURI(
250             "about:preferences?action=pair#sync",
251             true,
252             {
253               ignoreQueryString: true,
254               replaceQueryString: true,
255               adoptIntoActiveWindow: true,
256               ignoreFragment: "whenComparing",
257               triggeringPrincipal:
258                 Services.scriptSecurityManager.getSystemPrincipal(),
259             }
260           );
261           // close the tab
262           window.gBrowser.removeTab(selectedTab);
263         }
264         break;
265       case COMMAND_FIREFOX_VIEW:
266         this._helpers.openFirefoxView(browser, data.entryPoint);
267         break;
268       case COMMAND_CHANGE_PASSWORD:
269         this._helpers
270           .changePassword(data)
271           .catch(error => this._sendError(error, message, sendingContext));
272         break;
273       case COMMAND_FXA_STATUS:
274         log.debug("fxa_status received");
276         const service = data && data.service;
277         const isPairing = data && data.isPairing;
278         const context = data && data.context;
279         this._helpers
280           .getFxaStatus(service, sendingContext, isPairing, context)
281           .then(fxaStatus => {
282             let response = {
283               command,
284               messageId: message.messageId,
285               data: fxaStatus,
286             };
287             this._channel.send(response, sendingContext);
288           })
289           .catch(error => this._sendError(error, message, sendingContext));
290         break;
291       case COMMAND_PAIR_HEARTBEAT:
292       case COMMAND_PAIR_SUPP_METADATA:
293       case COMMAND_PAIR_AUTHORIZE:
294       case COMMAND_PAIR_DECLINE:
295       case COMMAND_PAIR_COMPLETE:
296         log.debug(`Pairing command ${command} received`);
297         const { channel_id: channelId } = data;
298         delete data.channel_id;
299         const flow = lazy.FxAccountsPairingFlow.get(channelId);
300         if (!flow) {
301           log.warn(`Could not find a pairing flow for ${channelId}`);
302           return;
303         }
304         flow.onWebChannelMessage(command, data).then(replyData => {
305           this._channel.send(
306             {
307               command,
308               messageId: message.messageId,
309               data: replyData,
310             },
311             sendingContext
312           );
313         });
314         break;
315       default:
316         log.warn("Unrecognized FxAccountsWebChannel command", command);
317         // As a safety measure we also terminate any pending FxA pairing flow.
318         lazy.FxAccountsPairingFlow.finalizeAll();
319         break;
320     }
321   },
323   _sendError(error, incomingMessage, sendingContext) {
324     log.error("Failed to handle FxAccountsWebChannel message", error);
325     this._channel.send(
326       {
327         command: incomingMessage.command,
328         messageId: incomingMessage.messageId,
329         data: {
330           error: getErrorDetails(error),
331         },
332       },
333       sendingContext
334     );
335   },
337   /**
338    * Create a new channel with the WebChannelBroker, setup a callback listener
339    * @private
340    */
341   _registerChannel() {
342     /**
343      * Processes messages that are called back from the FxAccountsChannel
344      *
345      * @param webChannelId {String}
346      *        Command webChannelId
347      * @param message {Object}
348      *        Command message
349      * @param sendingContext {Object}
350      *        Message sending context.
351      *        @param sendingContext.browsingContext {BrowsingContext}
352      *               The browsingcontext from which the
353      *               WebChannelMessageToChrome was sent.
354      *        @param sendingContext.eventTarget {EventTarget}
355      *               The <EventTarget> where the message was sent.
356      *        @param sendingContext.principal {Principal}
357      *               The <Principal> of the EventTarget where the message was sent.
358      * @private
359      *
360      */
361     let listener = (webChannelId, message, sendingContext) => {
362       if (message) {
363         log.debug("FxAccountsWebChannel message received", message.command);
364         if (logPII()) {
365           log.debug("FxAccountsWebChannel message details", message);
366         }
367         try {
368           this._receiveMessage(message, sendingContext);
369         } catch (error) {
370           this._sendError(error, message, sendingContext);
371         }
372       }
373     };
375     this._channelCallback = listener;
376     this._channel = new lazy.WebChannel(
377       this._webChannelId,
378       this._webChannelOrigin
379     );
380     this._channel.listen(listener);
381     log.debug(
382       "FxAccountsWebChannel registered: " +
383         this._webChannelId +
384         " with origin " +
385         this._webChannelOrigin.prePath
386     );
387   },
390 export function FxAccountsWebChannelHelpers(options) {
391   options = options || {};
393   this._fxAccounts = options.fxAccounts || lazy.fxAccounts;
394   this._weaveXPCOM = options.weaveXPCOM || null;
395   this._privateBrowsingUtils =
396     options.privateBrowsingUtils || lazy.PrivateBrowsingUtils;
399 FxAccountsWebChannelHelpers.prototype = {
400   // If the last fxa account used for sync isn't this account, we display
401   // a modal dialog checking they really really want to do this...
402   // (This is sync-specific, so ideally would be in sync's identity module,
403   // but it's a little more seamless to do here, and sync is currently the
404   // only fxa consumer, so...
405   shouldAllowRelink(acctName) {
406     return (
407       !this._needRelinkWarning(acctName) || this._promptForRelink(acctName)
408     );
409   },
411   /**
412    * stores sync login info it in the fxaccounts service
413    *
414    * @param accountData the user's account data and credentials
415    */
416   async login(accountData) {
417     // We don't act on customizeSync anymore, it used to open a dialog inside
418     // the browser to selecte the engines to sync but we do it on the web now.
419     log.debug("Webchannel is logging a user in.");
420     delete accountData.customizeSync;
422     // Save requested services for later.
423     const requestedServices = accountData.services;
424     delete accountData.services;
426     // the user has already been shown the "can link account"
427     // screen. No need to keep this data around.
428     delete accountData.verifiedCanLinkAccount;
430     // Remember who it was so we can log out next time.
431     if (accountData.verified) {
432       this.setPreviousAccountNameHashPref(accountData.email);
433     }
435     await this._fxAccounts.telemetry.recordConnection(
436       Object.keys(requestedServices || {}),
437       "webchannel"
438     );
440     // A sync-specific hack - we want to ensure sync has been initialized
441     // before we set the signed-in user.
442     // XXX - probably not true any more, especially now we have observerPreloads
443     // in FxAccounts.jsm?
444     let xps =
445       this._weaveXPCOM ||
446       Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports)
447         .wrappedJSObject;
448     await xps.whenLoaded();
449     await this._fxAccounts._internal.setSignedInUser(accountData);
451     if (requestedServices) {
452       // User has enabled Sync.
453       if (requestedServices.sync) {
454         const { offeredEngines, declinedEngines } = requestedServices.sync;
455         if (offeredEngines && declinedEngines) {
456           EXTRA_ENGINES.forEach(engine => {
457             if (
458               offeredEngines.includes(engine) &&
459               !declinedEngines.includes(engine)
460             ) {
461               // These extra engines are disabled by default.
462               Services.prefs.setBoolPref(
463                 `services.sync.engine.${engine}`,
464                 true
465               );
466             }
467           });
468           log.debug("Received declined engines", declinedEngines);
469           lazy.Weave.Service.engineManager.setDeclined(declinedEngines);
470           declinedEngines.forEach(engine => {
471             Services.prefs.setBoolPref(`services.sync.engine.${engine}`, false);
472           });
473         }
474         log.debug("Webchannel is enabling sync");
475         await xps.Weave.Service.configure();
476       }
477     }
478   },
480   /**
481    * logout the fxaccounts service
482    *
483    * @param the uid of the account which have been logged out
484    */
485   async logout(uid) {
486     let fxa = this._fxAccounts;
487     let userData = await fxa._internal.getUserAccountData(["uid"]);
488     if (userData && userData.uid === uid) {
489       await fxa.telemetry.recordDisconnection(null, "webchannel");
490       // true argument is `localOnly`, because server-side stuff
491       // has already been taken care of by the content server
492       await fxa.signOut(true);
493     }
494   },
496   /**
497    * Check if `sendingContext` is in private browsing mode.
498    */
499   isPrivateBrowsingMode(sendingContext) {
500     if (!sendingContext) {
501       log.error("Unable to check for private browsing mode, assuming true");
502       return true;
503     }
505     let browser = sendingContext.browsingContext.top.embedderElement;
506     const isPrivateBrowsing =
507       this._privateBrowsingUtils.isBrowserPrivate(browser);
508     log.debug("is private browsing", isPrivateBrowsing);
509     return isPrivateBrowsing;
510   },
512   /**
513    * Check whether sending fxa_status data should be allowed.
514    */
515   shouldAllowFxaStatus(service, sendingContext, isPairing, context) {
516     // Return user data for any service in non-PB mode. In PB mode,
517     // only return user data if service==="sync" or is in pairing mode
518     // (as service will be equal to the OAuth client ID and not "sync").
519     //
520     // This behaviour allows users to click the "Manage Account"
521     // link from about:preferences#sync while in PB mode and things
522     // "just work". While in non-PB mode, users can sign into
523     // Pocket w/o entering their password a 2nd time, while in PB
524     // mode they *will* have to enter their email/password again.
525     //
526     // The difference in behaviour is to try to match user
527     // expectations as to what is and what isn't part of the browser.
528     // Sync is viewed as an integral part of the browser, interacting
529     // with FxA as part of a Sync flow should work all the time. If
530     // Sync is broken in PB mode, users will think Firefox is broken.
531     // See https://bugzilla.mozilla.org/show_bug.cgi?id=1323853
532     log.debug("service", service);
533     return (
534       !this.isPrivateBrowsingMode(sendingContext) ||
535       service === "sync" ||
536       context === "fx_desktop_v3" ||
537       isPairing
538     );
539   },
541   /**
542    * Get fxa_status information. Resolves to { signedInUser: <user_data> }.
543    * If returning status information is not allowed or no user is signed into
544    * Sync, `user_data` will be null.
545    */
546   async getFxaStatus(service, sendingContext, isPairing, context) {
547     let signedInUser = null;
549     if (
550       this.shouldAllowFxaStatus(service, sendingContext, isPairing, context)
551     ) {
552       const userData = await this._fxAccounts._internal.getUserAccountData([
553         "email",
554         "sessionToken",
555         "uid",
556         "verified",
557       ]);
558       if (userData) {
559         signedInUser = {
560           email: userData.email,
561           sessionToken: userData.sessionToken,
562           uid: userData.uid,
563           verified: userData.verified,
564         };
565       }
566     }
568     return {
569       signedInUser,
570       clientId: FX_OAUTH_CLIENT_ID,
571       capabilities: {
572         multiService: true,
573         pairing: lazy.pairingEnabled,
574         engines: this._getAvailableExtraEngines(),
575       },
576     };
577   },
579   _getAvailableExtraEngines() {
580     return EXTRA_ENGINES.filter(engineName => {
581       try {
582         return Services.prefs.getBoolPref(
583           `services.sync.engine.${engineName}.available`
584         );
585       } catch (e) {
586         return false;
587       }
588     });
589   },
591   async changePassword(credentials) {
592     // If |credentials| has fields that aren't handled by accounts storage,
593     // updateUserAccountData will throw - mainly to prevent errors in code
594     // that hard-codes field names.
595     // However, in this case the field names aren't really in our control.
596     // We *could* still insist the server know what fields names are valid,
597     // but that makes life difficult for the server when Firefox adds new
598     // features (ie, new fields) - forcing the server to track a map of
599     // versions to supported field names doesn't buy us much.
600     // So we just remove field names we know aren't handled.
601     let newCredentials = {
602       device: null, // Force a brand new device registration.
603       // We force the re-encryption of the send tab keys using the new sync key after the password change
604       encryptedSendTabKeys: null,
605     };
606     for (let name of Object.keys(credentials)) {
607       if (
608         name == "email" ||
609         name == "uid" ||
610         lazy.FxAccountsStorageManagerCanStoreField(name)
611       ) {
612         newCredentials[name] = credentials[name];
613       } else {
614         log.info("changePassword ignoring unsupported field", name);
615       }
616     }
617     await this._fxAccounts._internal.updateUserAccountData(newCredentials);
618     await this._fxAccounts._internal.updateDeviceRegistration();
619   },
621   /**
622    * Get the hash of account name of the previously signed in account
623    */
624   getPreviousAccountNameHashPref() {
625     try {
626       return Services.prefs.getStringPref(PREF_LAST_FXA_USER);
627     } catch (_) {
628       return "";
629     }
630   },
632   /**
633    * Given an account name, set the hash of the previously signed in account
634    *
635    * @param acctName the account name of the user's account.
636    */
637   setPreviousAccountNameHashPref(acctName) {
638     Services.prefs.setStringPref(
639       PREF_LAST_FXA_USER,
640       lazy.CryptoUtils.sha256Base64(acctName)
641     );
642   },
644   /**
645    * Open Sync Preferences in the current tab of the browser
646    *
647    * @param {Object} browser the browser in which to open preferences
648    * @param {String} [entryPoint] entryPoint to use for logging
649    */
650   openSyncPreferences(browser, entryPoint) {
651     let uri = "about:preferences";
652     if (entryPoint) {
653       uri += "?entrypoint=" + encodeURIComponent(entryPoint);
654     }
655     uri += "#sync";
657     browser.loadURI(Services.io.newURI(uri), {
658       triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
659     });
660   },
662   /**
663    * Open Firefox View in the browser's window
664    *
665    * @param {Object} browser the browser in whose window we'll open Firefox View
666    * @param {String} [entryPoint] entryPoint Optional string to use for logging
667    */
668   openFirefoxView(browser, entryPoint) {
669     browser.ownerGlobal.FirefoxViewHandler.openTab(entryPoint);
670   },
672   /**
673    * If a user signs in using a different account, the data from the
674    * previous account and the new account will be merged. Ask the user
675    * if they want to continue.
676    *
677    * @private
678    */
679   _needRelinkWarning(acctName) {
680     let prevAcctHash = this.getPreviousAccountNameHashPref();
681     return (
682       prevAcctHash && prevAcctHash != lazy.CryptoUtils.sha256Base64(acctName)
683     );
684   },
686   /**
687    * Show the user a warning dialog that the data from the previous account
688    * and the new account will be merged.
689    *
690    * @private
691    */
692   _promptForRelink(acctName) {
693     let sb = Services.strings.createBundle(
694       "chrome://browser/locale/syncSetup.properties"
695     );
696     let continueLabel = sb.GetStringFromName("continue.label");
697     let title = sb.GetStringFromName("relinkVerify.title");
698     let description = sb.formatStringFromName("relinkVerify.description", [
699       acctName,
700     ]);
701     let body =
702       sb.GetStringFromName("relinkVerify.heading") + "\n\n" + description;
703     let ps = Services.prompt;
704     let buttonFlags =
705       ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING +
706       ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL +
707       ps.BUTTON_POS_1_DEFAULT;
709     // If running in context of the browser chrome, window does not exist.
710     let pressed = Services.prompt.confirmEx(
711       null,
712       title,
713       body,
714       buttonFlags,
715       continueLabel,
716       null,
717       null,
718       null,
719       {}
720     );
721     return pressed === 0; // 0 is the "continue" button
722   },
725 var singleton;
727 // The entry-point for this module, which ensures only one of our channels is
728 // ever created - we require this because the WebChannel is global in scope
729 // (eg, it uses the observer service to tell interested parties of interesting
730 // things) and allowing multiple channels would cause such notifications to be
731 // sent multiple times.
732 export var EnsureFxAccountsWebChannel = () => {
733   let contentUri = Services.urlFormatter.formatURLPref(
734     "identity.fxaccounts.remote.root"
735   );
736   if (singleton && singleton._contentUri !== contentUri) {
737     singleton.tearDown();
738     singleton = null;
739   }
740   if (!singleton) {
741     try {
742       if (contentUri) {
743         // The FxAccountsWebChannel listens for events and updates
744         // the state machine accordingly.
745         singleton = new FxAccountsWebChannel({
746           content_uri: contentUri,
747           channel_id: WEBCHANNEL_ID,
748         });
749       } else {
750         log.warn("FxA WebChannel functionaly is disabled due to no URI pref.");
751       }
752     } catch (ex) {
753       log.error("Failed to create FxA WebChannel", ex);
754     }
755   }