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/. */
6 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
8 const { XPCOMUtils } = ChromeUtils.import(
9 "resource://gre/modules/XPCOMUtils.jsm"
14 ERRNO_DEVICE_SESSION_CONFLICT,
17 ON_DEVICE_CONNECTED_NOTIFICATION,
18 ON_DEVICE_DISCONNECTED_NOTIFICATION,
19 ONVERIFIED_NOTIFICATION,
21 } = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
23 const { DEVICE_TYPE_DESKTOP } = ChromeUtils.import(
24 "resource://services-sync/constants.js"
27 ChromeUtils.defineModuleGetter(
30 "resource://services-common/utils.js"
33 const PREF_LOCAL_DEVICE_NAME = PREF_ACCOUNT_ROOT + "device.name";
34 XPCOMUtils.defineLazyPreferenceGetter(
36 "pref_localDeviceName",
37 PREF_LOCAL_DEVICE_NAME,
41 const PREF_DEPRECATED_DEVICE_NAME = "services.sync.client.name";
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
48 const INVALID_NAME_CHARS = /[\u0000-\u001F\u007F\u0080-\u009F\u2028-\u2029\uE000-\uF8FF\uFFF9-\uFFFC\uFFFE-\uFFFF]/g;
49 const MAX_NAME_LEN = 255;
50 const REPLACEMENT_CHAR = "\uFFFD";
52 function sanitizeDeviceName(name) {
54 .substr(0, MAX_NAME_LEN)
55 .replace(INVALID_NAME_CHARS, REPLACEMENT_CHAR);
58 // Everything to do with FxA devices.
59 class FxAccountsDevice {
62 this._deviceListCache = null;
63 this._fetchAndCacheDeviceListPromise = null;
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;
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
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);
82 return this._withCurrentAccountState(currentState => {
83 // It turns out _updateDeviceRegistrationIfNecessary() does exactly what we
85 return this._updateDeviceRegistrationIfNecessary(currentState);
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(
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.
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");
107 let brand = Services.strings.createBundle(
108 "chrome://branding/locale/brand.properties"
112 brandName = brand.GetStringFromName("brandShortName");
114 // this only fails in tests and markh can't work out why :(
115 brandName = Services.appinfo.name;
118 // The DNS service may fail to provide a hostname in edge-cases we don't
119 // fully understand - bug 1391488.
122 // hostname of the system, usually assigned by the user or admin
123 hostname = Cc["@mozilla.org/network/dns-service;1"].getService(
130 // 'device' is defined on unix systems
131 Services.sysinfo.get("device") ||
133 // fall back on ua info string
134 Cc["@mozilla.org/network/protocol;1?name=http"].getService(
135 Ci.nsIHttpProtocolHandler
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"
143 return sanitizeDeviceName(
144 syncStrings.formatStringFromName("client.name2", [
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,
159 if (deprecated_value) {
160 Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, deprecated_value);
161 Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME);
163 let name = pref_localDeviceName;
165 name = this.getDefaultLocalName();
166 Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, name);
168 // We need to sanitize here because some names were generated before we
169 // started sanitizing.
170 return sanitizeDeviceName(name);
173 setLocalName(newName) {
174 Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME);
175 Services.prefs.setStringPref(
176 PREF_LOCAL_DEVICE_NAME,
177 sanitizeDeviceName(newName)
179 // Update the registration in the background.
180 this.updateDeviceRegistration().catch(error => {
181 log.warn("failed to update fxa device registration", error);
186 return DEVICE_TYPE_DESKTOP;
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
197 get recentDeviceList() {
198 return this._deviceListCache ? this._deviceListCache.devices : null;
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
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
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;
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");
231 log.info("fetching updated device list");
232 this._fetchAndCacheDeviceListPromise = (async () => {
234 const devices = await this._withVerifiedAccountState(
235 async currentState => {
236 const accountData = await currentState.getUserAccountData([
240 const devices = await this._fxai.fxAccountsClient.getDeviceList(
241 accountData.sessionToken
244 `Got new device list: ${devices.map(d => d.id).join(", ")}`
247 await this._refreshRemoteDevice(currentState, accountData, devices);
251 log.info("updating the cache");
252 // Be careful to only update the cache once the above has resolved, so
253 // we know that the current account state didn't change underneath us.
254 this._deviceListCache = {
255 lastFetch: this._fxai.now(),
260 this._fetchAndCacheDeviceListPromise = null;
263 return this._fetchAndCacheDeviceListPromise;
266 async _refreshRemoteDevice(currentState, accountData, remoteDevices) {
267 // Check if our push registration previously succeeded and is still
268 // good (although background device registration means it's possible
269 // we'll be fetching the device list before we've actually
270 // registered ourself!)
271 // (For a missing subscription we check for an explicit 'null' -
272 // both to help tests and as a safety valve - missing might mean
273 // "no push available" for self-hosters or similar?)
274 const ourDevice = remoteDevices.find(device => device.isCurrentDevice);
277 (ourDevice.pushCallback === null || ourDevice.pushEndpointExpired)
279 log.warn(`Our push endpoint needs resubscription`);
280 await this._fxai.fxaPushService.unsubscribe();
281 await this._registerOrUpdateDevice(currentState, accountData);
282 // and there's a reasonable chance there are commands waiting.
283 await this._fxai.commands.pollDeviceCommands();
286 (await this._checkRemoteCommandsUpdateNeeded(ourDevice.availableCommands))
288 log.warn(`Our commands need to be updated on the server`);
289 await this._registerOrUpdateDevice(currentState, accountData);
293 async updateDeviceRegistration() {
294 return this._withCurrentAccountState(async currentState => {
295 const signedInUser = await currentState.getUserAccountData([
300 await this._registerOrUpdateDevice(currentState, signedInUser);
305 async updateDeviceRegistrationIfNecessary() {
306 return this._withCurrentAccountState(currentState => {
307 return this._updateDeviceRegistrationIfNecessary(currentState);
312 this._deviceListCache = null;
313 this._fetchAndCacheDeviceListPromise = null;
317 * Here begin our internal helper methods.
319 * Many of these methods take the current account state as first argument,
320 * in order to avoid racing our state updates with e.g. the uer signing
321 * out while we're in the middle of an update. If this does happen, the
322 * resulting promise will be rejected rather than persisting stale state.
326 _withCurrentAccountState(func) {
327 return this._fxai.withCurrentAccountState(async currentState => {
329 return await func(currentState);
331 // `_handleTokenError` always throws, this syntax keeps the linter happy.
332 // TODO: probably `_handleTokenError` could be done by `_fxai.withCurrentAccountState`
333 // internally rather than us having to remember to do it here.
334 throw await this._fxai._handleTokenError(err);
339 _withVerifiedAccountState(func) {
340 return this._fxai.withVerifiedAccountState(async currentState => {
342 return await func(currentState);
344 // `_handleTokenError` always throws, this syntax keeps the linter happy.
345 throw await this._fxai._handleTokenError(err);
350 async _checkDeviceUpdateNeeded(device) {
351 // There is no device registered or the device registration is outdated.
352 // Either way, we should register the device with FxA
353 // before returning the id to the caller.
354 const availableCommandsKeys = Object.keys(
355 await this._fxai.commands.availableCommands()
359 !device.registrationVersion ||
360 device.registrationVersion < this.DEVICE_REGISTRATION_VERSION ||
361 !device.registeredCommandsKeys ||
362 !CommonUtils.arrayEqual(
363 device.registeredCommandsKeys,
364 availableCommandsKeys
369 async _checkRemoteCommandsUpdateNeeded(remoteAvailableCommands) {
370 if (!remoteAvailableCommands) {
373 const remoteAvailableCommandsKeys = Object.keys(
374 remoteAvailableCommands
376 const localAvailableCommands = await this._fxai.commands.availableCommands();
377 const localAvailableCommandsKeys = Object.keys(
378 localAvailableCommands
382 !CommonUtils.arrayEqual(
383 localAvailableCommandsKeys,
384 remoteAvailableCommandsKeys
390 for (const key of localAvailableCommandsKeys) {
391 if (remoteAvailableCommands[key] !== localAvailableCommands[key]) {
398 async _updateDeviceRegistrationIfNecessary(currentState) {
399 let data = await currentState.getUserAccountData([
404 // Can't register a device without a signed-in user.
407 const { device } = data;
408 if (await this._checkDeviceUpdateNeeded(device)) {
409 return this._registerOrUpdateDevice(currentState, data);
411 // Return the device ID we already had.
415 // If you change what we send to the FxA servers during device registration,
416 // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older
417 // devices to re-register when Firefox updates.
418 async _registerOrUpdateDevice(currentState, signedInUser) {
419 // This method has the side-effect of setting some account-related prefs
420 // (e.g. for caching the device name) so it's important we don't execute it
421 // if the signed-in state has changed.
422 if (!currentState.isCurrent) {
424 "_registerOrUpdateDevice called after a different user has signed in"
428 const { sessionToken, device: currentDevice } = signedInUser;
430 throw new Error("_registerOrUpdateDevice called without a session token");
434 const subscription = await this._fxai.fxaPushService.registerPushEndpoint();
435 const deviceName = this.getLocalName();
436 let deviceOptions = {};
438 // if we were able to obtain a subscription
439 if (subscription && subscription.endpoint) {
440 deviceOptions.pushCallback = subscription.endpoint;
441 let publicKey = subscription.getKey("p256dh");
442 let authKey = subscription.getKey("auth");
443 if (publicKey && authKey) {
444 deviceOptions.pushPublicKey = urlsafeBase64Encode(publicKey);
445 deviceOptions.pushAuthKey = urlsafeBase64Encode(authKey);
448 deviceOptions.availableCommands = await this._fxai.commands.availableCommands();
449 const availableCommandsKeys = Object.keys(
450 deviceOptions.availableCommands
452 log.info("registering with available commands", availableCommandsKeys);
455 if (currentDevice && currentDevice.id) {
456 log.debug("updating existing device details");
457 device = await this._fxai.fxAccountsClient.updateDevice(
464 log.debug("registering new device details");
465 device = await this._fxai.fxAccountsClient.registerDevice(
471 Services.obs.notifyObservers(null, ON_NEW_DEVICE_ID);
474 // Get the freshest device props before updating them.
475 let { device: deviceProps } = await currentState.getUserAccountData([
478 await currentState.updateUserAccountData({
480 ...deviceProps, // Copy the other properties (e.g. handledCommands).
482 registrationVersion: this.DEVICE_REGISTRATION_VERSION,
483 registeredCommandsKeys: availableCommandsKeys,
488 return this._handleDeviceError(currentState, error, sessionToken);
492 async _handleDeviceError(currentState, error, sessionToken) {
494 if (error.code === 400) {
495 if (error.errno === ERRNO_UNKNOWN_DEVICE) {
496 return this._recoverFromUnknownDevice(currentState);
499 if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) {
500 return this._recoverFromDeviceSessionConflict(
508 // `_handleTokenError` always throws, this syntax keeps the linter happy.
509 // Note that the re-thrown error is immediately caught, logged and ignored
510 // by the containing scope here, which is why we have to `_handleTokenError`
511 // ourselves rather than letting it bubble up for handling by the caller.
512 throw await this._fxai._handleTokenError(error);
514 await this._logErrorAndResetDeviceRegistrationVersion(
522 async _recoverFromUnknownDevice(currentState) {
523 // FxA did not recognise the device id. Handle it by clearing the device
524 // id on the account data. At next sync or next sign-in, registration is
525 // retried and should succeed.
526 log.warn("unknown device id, clearing the local device data");
528 await currentState.updateUserAccountData({
530 encryptedSendTabKeys: null,
533 await this._logErrorAndResetDeviceRegistrationVersion(
541 async _recoverFromDeviceSessionConflict(currentState, error, sessionToken) {
542 // FxA has already associated this session with a different device id.
543 // Perhaps we were beaten in a race to register. Handle the conflict:
544 // 1. Fetch the list of devices for the current user from FxA.
545 // 2. Look for ourselves in the list.
546 // 3. If we find a match, set the correct device id and device registration
547 // version on the account data and return the correct device id. At next
548 // sync or next sign-in, registration is retried and should succeed.
549 // 4. If we don't find a match, log the original error.
551 "device session conflict, attempting to ascertain the correct device id"
554 const devices = await this._fxai.fxAccountsClient.getDeviceList(
557 const matchingDevices = devices.filter(device => device.isCurrentDevice);
558 const length = matchingDevices.length;
560 const deviceId = matchingDevices[0].id;
561 await currentState.updateUserAccountData({
564 registrationVersion: null,
566 encryptedSendTabKeys: null,
572 "insane server state, " + length + " devices for this session"
575 await this._logErrorAndResetDeviceRegistrationVersion(
579 } catch (secondError) {
580 log.error("failed to recover from device-session conflict", secondError);
581 await this._logErrorAndResetDeviceRegistrationVersion(
589 async _logErrorAndResetDeviceRegistrationVersion(currentState, error) {
590 // Device registration should never cause other operations to fail.
591 // If we've reached this point, just log the error and reset the device
592 // on the account data. At next sync or next sign-in,
593 // registration will be retried.
594 log.error("device registration failed", error);
596 await currentState.updateUserAccountData({
598 encryptedSendTabKeys: null,
600 } catch (secondError) {
602 "failed to reset the device registration version, device registration won't be retried",
608 // Kick off a background refresh when a device is connected or disconnected.
609 observe(subject, topic, data) {
611 case ON_DEVICE_CONNECTED_NOTIFICATION:
612 this.refreshDeviceList({ ignoreCached: true }).catch(error => {
614 "failed to refresh devices after connecting a new device",
619 case ON_DEVICE_DISCONNECTED_NOTIFICATION:
620 let json = JSON.parse(data);
621 if (!json.isLocalDevice) {
622 // If we're the device being disconnected, don't bother fetching a new
623 // list, since our session token is now invalid.
624 this.refreshDeviceList({ ignoreCached: true }).catch(error => {
626 "failed to refresh devices after disconnecting a device",
632 case ONVERIFIED_NOTIFICATION:
633 this.updateDeviceRegistrationIfNecessary().catch(error => {
635 "updateDeviceRegistrationIfNecessary failed after verification",
644 FxAccountsDevice.prototype.QueryInterface = ChromeUtils.generateQI([
646 "nsISupportsWeakReference",
649 function urlsafeBase64Encode(buffer) {
650 return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
653 var EXPORTED_SYMBOLS = ["FxAccountsDevice"];