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