FxAccountsDevice.sys.mjs (22575B)
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 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 6 7 import { 8 log, 9 ERRNO_DEVICE_SESSION_CONFLICT, 10 ERRNO_UNKNOWN_DEVICE, 11 ON_NEW_DEVICE_ID, 12 ON_DEVICELIST_UPDATED, 13 ON_DEVICE_CONNECTED_NOTIFICATION, 14 ON_DEVICE_DISCONNECTED_NOTIFICATION, 15 ONVERIFIED_NOTIFICATION, 16 PREF_ACCOUNT_ROOT, 17 } from "resource://gre/modules/FxAccountsCommon.sys.mjs"; 18 19 import { DEVICE_TYPE_DESKTOP } from "resource://services-sync/constants.sys.mjs"; 20 21 const lazy = {}; 22 23 ChromeUtils.defineESModuleGetters(lazy, { 24 CommonUtils: "resource://services-common/utils.sys.mjs", 25 }); 26 27 const PREF_LOCAL_DEVICE_NAME = PREF_ACCOUNT_ROOT + "device.name"; 28 XPCOMUtils.defineLazyPreferenceGetter( 29 lazy, 30 "pref_localDeviceName", 31 PREF_LOCAL_DEVICE_NAME, 32 "" 33 ); 34 35 const PREF_DEPRECATED_DEVICE_NAME = "services.sync.client.name"; 36 37 // Sanitizes all characters which the FxA server considers invalid, replacing 38 // them with the unicode replacement character. 39 // At time of writing, FxA has a regex DISPLAY_SAFE_UNICODE_WITH_NON_BMP, which 40 // the regex below is based on. 41 const INVALID_NAME_CHARS = 42 // eslint-disable-next-line no-control-regex 43 /[\u0000-\u001F\u007F\u0080-\u009F\u2028-\u2029\uE000-\uF8FF\uFFF9-\uFFFC\uFFFE-\uFFFF]/g; 44 const MAX_NAME_LEN = 255; 45 const REPLACEMENT_CHAR = "\uFFFD"; 46 47 function sanitizeDeviceName(name) { 48 return name 49 .substr(0, MAX_NAME_LEN) 50 .replace(INVALID_NAME_CHARS, REPLACEMENT_CHAR); 51 } 52 53 // Everything to do with FxA devices. 54 export class FxAccountsDevice { 55 constructor(fxai) { 56 this._fxai = fxai; 57 this._deviceListCache = null; 58 this._fetchAndCacheDeviceListPromise = null; 59 60 // The current version of the device registration, we use this to re-register 61 // devices after we update what we send on device registration. 62 this.DEVICE_REGISTRATION_VERSION = 2; 63 64 // This is to avoid multiple sequential syncs ending up calling 65 // this expensive endpoint multiple times in a row. 66 this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS = 1 * 60 * 1000; // 1 minute 67 68 // Invalidate our cached device list when a device is connected or disconnected. 69 Services.obs.addObserver(this, ON_DEVICE_CONNECTED_NOTIFICATION, true); 70 Services.obs.addObserver(this, ON_DEVICE_DISCONNECTED_NOTIFICATION, true); 71 // A user becoming verified probably means we need to re-register the device 72 // because we are now able to get the sendtab keys. 73 Services.obs.addObserver(this, ONVERIFIED_NOTIFICATION, true); 74 } 75 76 async getLocalId() { 77 return this._withCurrentAccountState(currentState => { 78 // It turns out _updateDeviceRegistrationIfNecessary() does exactly what we 79 // need. 80 return this._updateDeviceRegistrationIfNecessary(currentState); 81 }); 82 } 83 84 // Generate a client name if we don't have a useful one yet 85 getDefaultLocalName() { 86 let user = Services.env.get("USER") || Services.env.get("USERNAME"); 87 // Note that we used to fall back to the "services.sync.username" pref here, 88 // but that's no longer suitable in a world where sync might not be 89 // configured. However, we almost never *actually* fell back to that, and 90 // doing so sanely here would mean making this function async, which we don't 91 // really want to do yet. 92 93 // A little hack for people using the the moz-build environment on Windows 94 // which sets USER to the literal "%USERNAME%" (yes, really) 95 if (user == "%USERNAME%" && Services.env.get("USERNAME")) { 96 user = Services.env.get("USERNAME"); 97 } 98 99 // The DNS service may fail to provide a hostname in edge-cases we don't 100 // fully understand - bug 1391488. 101 let hostname; 102 try { 103 // hostname of the system, usually assigned by the user or admin 104 hostname = Services.dns.myHostName; 105 } catch (ex) { 106 console.error(ex); 107 } 108 let system = 109 // 'device' is defined on unix systems 110 Services.sysinfo.get("device") || 111 hostname || 112 // fall back on ua info string 113 Cc["@mozilla.org/network/protocol;1?name=http"].getService( 114 Ci.nsIHttpProtocolHandler 115 ).oscpu; 116 117 const l10n = new Localization( 118 ["services/accounts.ftl", "branding/brand.ftl"], 119 true 120 ); 121 return sanitizeDeviceName( 122 l10n.formatValueSync("account-client-name", { user, system }) 123 ); 124 } 125 126 getLocalName() { 127 // We used to store this in services.sync.client.name, but now store it 128 // under an fxa-specific location. 129 let deprecated_value = Services.prefs.getStringPref( 130 PREF_DEPRECATED_DEVICE_NAME, 131 "" 132 ); 133 if (deprecated_value) { 134 Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, deprecated_value); 135 Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME); 136 } 137 let name = lazy.pref_localDeviceName; 138 if (!name) { 139 name = this.getDefaultLocalName(); 140 Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, name); 141 } 142 // We need to sanitize here because some names were generated before we 143 // started sanitizing. 144 return sanitizeDeviceName(name); 145 } 146 147 setLocalName(newName) { 148 Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME); 149 Services.prefs.setStringPref( 150 PREF_LOCAL_DEVICE_NAME, 151 sanitizeDeviceName(newName) 152 ); 153 // Update the registration in the background. 154 this.updateDeviceRegistration().catch(error => { 155 log.warn("failed to update fxa device registration", error); 156 }); 157 } 158 159 getLocalType() { 160 return DEVICE_TYPE_DESKTOP; 161 } 162 163 /** 164 * Returns the most recently fetched device list, or `null` if the list 165 * hasn't been fetched yet. This is synchronous, so that consumers like 166 * Send Tab can render the device list right away, without waiting for 167 * it to refresh. 168 * 169 * @type {?Array} 170 */ 171 get recentDeviceList() { 172 return this._deviceListCache ? this._deviceListCache.devices : null; 173 } 174 175 /** 176 * Refreshes the device list. After this function returns, consumers can 177 * access the new list using the `recentDeviceList` getter. Note that 178 * multiple concurrent calls to `refreshDeviceList` will only refresh the 179 * list once. 180 * 181 * @param {boolean} [options.ignoreCached] 182 * If `true`, forces a refresh, even if the cached device list is 183 * still fresh. Defaults to `false`. 184 * @return {Promise<boolean>} 185 * `true` if the list was refreshed, `false` if the cached list is 186 * fresh. Rejects if an error occurs refreshing the list or device 187 * push registration. 188 */ 189 async refreshDeviceList({ ignoreCached = false } = {}) { 190 // If we're already refreshing the list in the background, let that finish. 191 if (this._fetchAndCacheDeviceListPromise) { 192 log.info("Already fetching device list, return existing promise"); 193 return this._fetchAndCacheDeviceListPromise; 194 } 195 196 // If the cache is fresh enough, don't refresh it again. 197 if (!ignoreCached && this._deviceListCache) { 198 const ageOfCache = this._fxai.now() - this._deviceListCache.lastFetch; 199 if (ageOfCache < this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS) { 200 log.info("Device list cache is fresh, re-using it"); 201 return false; 202 } 203 } 204 205 log.info("fetching updated device list"); 206 this._fetchAndCacheDeviceListPromise = (async () => { 207 try { 208 const devices = await this._withVerifiedAccountState( 209 async currentState => { 210 const accountData = await currentState.getUserAccountData([ 211 "sessionToken", 212 "device", 213 ]); 214 const devices = await this._fxai.fxAccountsClient.getDeviceList( 215 accountData.sessionToken 216 ); 217 log.info( 218 `Got new device list: ${devices.map(d => d.id).join(", ")}` 219 ); 220 221 await this._refreshRemoteDevice(currentState, accountData, devices); 222 return devices; 223 } 224 ); 225 log.info("updating the cache"); 226 // Be careful to only update the cache once the above has resolved, so 227 // we know that the current account state didn't change underneath us. 228 this._deviceListCache = { 229 lastFetch: this._fxai.now(), 230 devices, 231 }; 232 Services.obs.notifyObservers(null, ON_DEVICELIST_UPDATED); 233 return true; 234 } finally { 235 this._fetchAndCacheDeviceListPromise = null; 236 } 237 })(); 238 return this._fetchAndCacheDeviceListPromise; 239 } 240 241 async _refreshRemoteDevice(currentState, accountData, remoteDevices) { 242 // Check if our push registration previously succeeded and is still 243 // good (although background device registration means it's possible 244 // we'll be fetching the device list before we've actually 245 // registered ourself!) 246 // (For a missing subscription we check for an explicit 'null' - 247 // both to help tests and as a safety valve - missing might mean 248 // "no push available" for self-hosters or similar?) 249 const ourDevice = remoteDevices.find(device => device.isCurrentDevice); 250 const subscription = await this._fxai.fxaPushService.getSubscription(); 251 if ( 252 ourDevice && 253 (ourDevice.pushCallback === null || // fxa server doesn't know our subscription. 254 ourDevice.pushEndpointExpired || // fxa server thinks it has expired. 255 !subscription || // we don't have a local subscription. 256 subscription.isExpired() || // our local subscription is expired. 257 ourDevice.pushCallback != subscription.endpoint) // we don't agree with fxa. 258 ) { 259 log.warn(`Our push endpoint needs resubscription`); 260 await this._fxai.fxaPushService.unsubscribe(); 261 await this._registerOrUpdateDevice(currentState, accountData); 262 // and there's a reasonable chance there are commands waiting. 263 await this._fxai.commands.pollDeviceCommands(); 264 } else if ( 265 ourDevice && 266 (await this._checkRemoteCommandsUpdateNeeded(ourDevice.availableCommands)) 267 ) { 268 log.warn(`Our commands need to be updated on the server`); 269 await this._registerOrUpdateDevice(currentState, accountData); 270 } else { 271 log.trace(`Our push subscription looks OK`); 272 } 273 } 274 275 async updateDeviceRegistration() { 276 return this._withCurrentAccountState(async currentState => { 277 const signedInUser = await currentState.getUserAccountData([ 278 "sessionToken", 279 "device", 280 ]); 281 if (signedInUser) { 282 await this._registerOrUpdateDevice(currentState, signedInUser); 283 } 284 }); 285 } 286 287 async updateDeviceRegistrationIfNecessary() { 288 return this._withCurrentAccountState(currentState => { 289 return this._updateDeviceRegistrationIfNecessary(currentState); 290 }); 291 } 292 293 reset() { 294 this._deviceListCache = null; 295 this._fetchAndCacheDeviceListPromise = null; 296 } 297 298 /** 299 * Here begin our internal helper methods. 300 * 301 * Many of these methods take the current account state as first argument, 302 * in order to avoid racing our state updates with e.g. the uer signing 303 * out while we're in the middle of an update. If this does happen, the 304 * resulting promise will be rejected rather than persisting stale state. 305 * 306 */ 307 308 _withCurrentAccountState(func) { 309 return this._fxai.withCurrentAccountState(async currentState => { 310 try { 311 return await func(currentState); 312 } catch (err) { 313 // `_handleTokenError` always throws, this syntax keeps the linter happy. 314 // TODO: probably `_handleTokenError` could be done by `_fxai.withCurrentAccountState` 315 // internally rather than us having to remember to do it here. 316 throw await this._fxai._handleTokenError(err); 317 } 318 }); 319 } 320 321 _withVerifiedAccountState(func) { 322 return this._fxai.withVerifiedAccountState(async currentState => { 323 try { 324 return await func(currentState); 325 } catch (err) { 326 // `_handleTokenError` always throws, this syntax keeps the linter happy. 327 throw await this._fxai._handleTokenError(err); 328 } 329 }); 330 } 331 332 async _checkDeviceUpdateNeeded(device) { 333 // There is no device registered or the device registration is outdated. 334 // Either way, we should register the device with FxA 335 // before returning the id to the caller. 336 const availableCommandsKeys = Object.keys( 337 await this._fxai.commands.availableCommands() 338 ).sort(); 339 return ( 340 !device || 341 !device.registrationVersion || 342 device.registrationVersion < this.DEVICE_REGISTRATION_VERSION || 343 !device.registeredCommandsKeys || 344 !lazy.CommonUtils.arrayEqual( 345 device.registeredCommandsKeys, 346 availableCommandsKeys 347 ) 348 ); 349 } 350 351 async _checkRemoteCommandsUpdateNeeded(remoteAvailableCommands) { 352 if (!remoteAvailableCommands) { 353 return true; 354 } 355 const remoteAvailableCommandsKeys = Object.keys( 356 remoteAvailableCommands 357 ).sort(); 358 const localAvailableCommands = 359 await this._fxai.commands.availableCommands(); 360 const localAvailableCommandsKeys = Object.keys( 361 localAvailableCommands 362 ).sort(); 363 364 if ( 365 !lazy.CommonUtils.arrayEqual( 366 localAvailableCommandsKeys, 367 remoteAvailableCommandsKeys 368 ) 369 ) { 370 return true; 371 } 372 373 for (const key of localAvailableCommandsKeys) { 374 if (remoteAvailableCommands[key] !== localAvailableCommands[key]) { 375 return true; 376 } 377 } 378 return false; 379 } 380 381 async _updateDeviceRegistrationIfNecessary(currentState) { 382 let data = await currentState.getUserAccountData([ 383 "sessionToken", 384 "device", 385 ]); 386 if (!data) { 387 // Can't register a device without a signed-in user. 388 return null; 389 } 390 const { device } = data; 391 if (await this._checkDeviceUpdateNeeded(device)) { 392 return this._registerOrUpdateDevice(currentState, data); 393 } 394 // Return the device ID we already had. 395 return device.id; 396 } 397 398 // If you change what we send to the FxA servers during device registration, 399 // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older 400 // devices to re-register when Firefox updates. 401 async _registerOrUpdateDevice(currentState, signedInUser) { 402 // This method has the side-effect of setting some account-related prefs 403 // (e.g. for caching the device name) so it's important we don't execute it 404 // if the signed-in state has changed. 405 if (!currentState.isCurrent) { 406 throw new Error( 407 "_registerOrUpdateDevice called after a different user has signed in" 408 ); 409 } 410 411 const { sessionToken, device: currentDevice } = signedInUser; 412 if (!sessionToken) { 413 throw new Error("_registerOrUpdateDevice called without a session token"); 414 } 415 416 try { 417 const subscription = 418 await this._fxai.fxaPushService.registerPushEndpoint(); 419 const deviceName = this.getLocalName(); 420 let deviceOptions = {}; 421 422 // if we were able to obtain a subscription 423 if (subscription && subscription.endpoint) { 424 deviceOptions.pushCallback = subscription.endpoint; 425 let publicKey = subscription.getKey("p256dh"); 426 let authKey = subscription.getKey("auth"); 427 if (publicKey && authKey) { 428 deviceOptions.pushPublicKey = urlsafeBase64Encode(publicKey); 429 deviceOptions.pushAuthKey = urlsafeBase64Encode(authKey); 430 } 431 } 432 deviceOptions.availableCommands = 433 await this._fxai.commands.availableCommands(); 434 const availableCommandsKeys = Object.keys( 435 deviceOptions.availableCommands 436 ).sort(); 437 log.info("registering with available commands", availableCommandsKeys); 438 439 let device; 440 let is_existing = currentDevice && currentDevice.id; 441 if (is_existing) { 442 log.debug("updating existing device details"); 443 device = await this._fxai.fxAccountsClient.updateDevice( 444 sessionToken, 445 currentDevice.id, 446 deviceName, 447 deviceOptions 448 ); 449 } else { 450 log.debug("registering new device details"); 451 device = await this._fxai.fxAccountsClient.registerDevice( 452 sessionToken, 453 deviceName, 454 this.getLocalType(), 455 deviceOptions 456 ); 457 } 458 459 // Get the freshest device props before updating them. 460 let { device: deviceProps } = await currentState.getUserAccountData([ 461 "device", 462 ]); 463 await currentState.updateUserAccountData({ 464 device: { 465 ...deviceProps, // Copy the other properties (e.g. handledCommands). 466 id: device.id, 467 registrationVersion: this.DEVICE_REGISTRATION_VERSION, 468 registeredCommandsKeys: availableCommandsKeys, 469 }, 470 }); 471 // Must send the notification after we've written the storage. 472 if (!is_existing) { 473 Services.obs.notifyObservers(null, ON_NEW_DEVICE_ID); 474 } 475 return device.id; 476 } catch (error) { 477 return this._handleDeviceError(currentState, error, sessionToken); 478 } 479 } 480 481 async _handleDeviceError(currentState, error, sessionToken) { 482 try { 483 if (error.code === 400) { 484 if (error.errno === ERRNO_UNKNOWN_DEVICE) { 485 return this._recoverFromUnknownDevice(currentState); 486 } 487 488 if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) { 489 return this._recoverFromDeviceSessionConflict( 490 currentState, 491 error, 492 sessionToken 493 ); 494 } 495 } 496 497 // `_handleTokenError` always throws, this syntax keeps the linter happy. 498 // Note that the re-thrown error is immediately caught, logged and ignored 499 // by the containing scope here, which is why we have to `_handleTokenError` 500 // ourselves rather than letting it bubble up for handling by the caller. 501 throw await this._fxai._handleTokenError(error); 502 } catch (error) { 503 await this._logErrorAndResetDeviceRegistrationVersion( 504 currentState, 505 error 506 ); 507 return null; 508 } 509 } 510 511 async _recoverFromUnknownDevice(currentState) { 512 // FxA did not recognise the device id. Handle it by clearing the device 513 // id on the account data. At next sync or next sign-in, registration is 514 // retried and should succeed. 515 log.warn("unknown device id, clearing the local device data"); 516 try { 517 await currentState.updateUserAccountData({ 518 device: null, 519 encryptedSendTabKeys: null, 520 }); 521 } catch (error) { 522 await this._logErrorAndResetDeviceRegistrationVersion( 523 currentState, 524 error 525 ); 526 } 527 return null; 528 } 529 530 async _recoverFromDeviceSessionConflict(currentState, error, sessionToken) { 531 // FxA has already associated this session with a different device id. 532 // Perhaps we were beaten in a race to register. Handle the conflict: 533 // 1. Fetch the list of devices for the current user from FxA. 534 // 2. Look for ourselves in the list. 535 // 3. If we find a match, set the correct device id and device registration 536 // version on the account data and return the correct device id. At next 537 // sync or next sign-in, registration is retried and should succeed. 538 // 4. If we don't find a match, log the original error. 539 log.warn( 540 "device session conflict, attempting to ascertain the correct device id" 541 ); 542 try { 543 const devices = 544 await this._fxai.fxAccountsClient.getDeviceList(sessionToken); 545 const matchingDevices = devices.filter(device => device.isCurrentDevice); 546 const length = matchingDevices.length; 547 if (length === 1) { 548 const deviceId = matchingDevices[0].id; 549 await currentState.updateUserAccountData({ 550 device: { 551 id: deviceId, 552 registrationVersion: null, 553 }, 554 encryptedSendTabKeys: null, 555 }); 556 return deviceId; 557 } 558 if (length > 1) { 559 log.error( 560 "insane server state, " + length + " devices for this session" 561 ); 562 } 563 await this._logErrorAndResetDeviceRegistrationVersion( 564 currentState, 565 error 566 ); 567 } catch (secondError) { 568 log.error("failed to recover from device-session conflict", secondError); 569 await this._logErrorAndResetDeviceRegistrationVersion( 570 currentState, 571 error 572 ); 573 } 574 return null; 575 } 576 577 async _logErrorAndResetDeviceRegistrationVersion(currentState, error) { 578 // Device registration should never cause other operations to fail. 579 // If we've reached this point, just log the error and reset the device 580 // on the account data. At next sync or next sign-in, 581 // registration will be retried. 582 log.error("device registration failed", error); 583 try { 584 await currentState.updateUserAccountData({ 585 device: null, 586 encryptedSendTabKeys: null, 587 }); 588 } catch (secondError) { 589 log.error( 590 "failed to reset the device registration version, device registration won't be retried", 591 secondError 592 ); 593 } 594 } 595 596 // Kick off a background refresh when a device is connected or disconnected. 597 observe(subject, topic, data) { 598 switch (topic) { 599 case ON_DEVICE_CONNECTED_NOTIFICATION: 600 this.refreshDeviceList({ ignoreCached: true }).catch(error => { 601 log.warn( 602 "failed to refresh devices after connecting a new device", 603 error 604 ); 605 }); 606 break; 607 case ON_DEVICE_DISCONNECTED_NOTIFICATION: { 608 let json = JSON.parse(data); 609 if (!json.isLocalDevice) { 610 // If we're the device being disconnected, don't bother fetching a new 611 // list, since our session token is now invalid. 612 this.refreshDeviceList({ ignoreCached: true }).catch(error => { 613 log.warn( 614 "failed to refresh devices after disconnecting a device", 615 error 616 ); 617 }); 618 } 619 break; 620 } 621 case ONVERIFIED_NOTIFICATION: 622 this.updateDeviceRegistrationIfNecessary().catch(error => { 623 log.warn( 624 "updateDeviceRegistrationIfNecessary failed after verification", 625 error 626 ); 627 }); 628 break; 629 } 630 } 631 } 632 633 FxAccountsDevice.prototype.QueryInterface = ChromeUtils.generateQI([ 634 "nsIObserver", 635 "nsISupportsWeakReference", 636 ]); 637 638 function urlsafeBase64Encode(buffer) { 639 return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false }); 640 }