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 };