Bug 1768570 [wpt PR 34013] - Update wpt metadata, a=testonly
[gecko.git] / services / fxaccounts / FxAccountsDevice.jsm
blobae273302c9d0b4d07d2751fe56314efa5b61bf27
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";
6 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
8 const { XPCOMUtils } = ChromeUtils.import(
9   "resource://gre/modules/XPCOMUtils.jsm"
12 const {
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");
23 const { DEVICE_TYPE_DESKTOP } = ChromeUtils.import(
24   "resource://services-sync/constants.js"
27 ChromeUtils.defineModuleGetter(
28   this,
29   "CommonUtils",
30   "resource://services-common/utils.js"
33 const PREF_LOCAL_DEVICE_NAME = PREF_ACCOUNT_ROOT + "device.name";
34 XPCOMUtils.defineLazyPreferenceGetter(
35   this,
36   "pref_localDeviceName",
37   PREF_LOCAL_DEVICE_NAME,
38   ""
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) {
53   return name
54     .substr(0, MAX_NAME_LEN)
55     .replace(INVALID_NAME_CHARS, REPLACEMENT_CHAR);
58 // Everything to do with FxA devices.
59 class FxAccountsDevice {
60   constructor(fxai) {
61     this._fxai = fxai;
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);
79   }
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   }
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.
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     }
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     }
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;
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   }
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   }
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   }
185   getLocalType() {
186     return DEVICE_TYPE_DESKTOP;
187   }
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   }
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     }
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     }
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             );
247             await this._refreshRemoteDevice(currentState, accountData, devices);
248             return devices;
249           }
250         );
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(),
256           devices,
257         };
258         return true;
259       } finally {
260         this._fetchAndCacheDeviceListPromise = null;
261       }
262     })();
263     return this._fetchAndCacheDeviceListPromise;
264   }
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);
275     if (
276       ourDevice &&
277       (ourDevice.pushCallback === null || ourDevice.pushEndpointExpired)
278     ) {
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();
284     } else if (
285       ourDevice &&
286       (await this._checkRemoteCommandsUpdateNeeded(ourDevice.availableCommands))
287     ) {
288       log.warn(`Our commands need to be updated on the server`);
289       await this._registerOrUpdateDevice(currentState, accountData);
290     }
291   }
293   async updateDeviceRegistration() {
294     return this._withCurrentAccountState(async currentState => {
295       const signedInUser = await currentState.getUserAccountData([
296         "sessionToken",
297         "device",
298       ]);
299       if (signedInUser) {
300         await this._registerOrUpdateDevice(currentState, signedInUser);
301       }
302     });
303   }
305   async updateDeviceRegistrationIfNecessary() {
306     return this._withCurrentAccountState(currentState => {
307       return this._updateDeviceRegistrationIfNecessary(currentState);
308     });
309   }
311   reset() {
312     this._deviceListCache = null;
313     this._fetchAndCacheDeviceListPromise = null;
314   }
316   /**
317    * Here begin our internal helper methods.
318    *
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.
323    *
324    */
326   _withCurrentAccountState(func) {
327     return this._fxai.withCurrentAccountState(async currentState => {
328       try {
329         return await func(currentState);
330       } catch (err) {
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);
335       }
336     });
337   }
339   _withVerifiedAccountState(func) {
340     return this._fxai.withVerifiedAccountState(async currentState => {
341       try {
342         return await func(currentState);
343       } catch (err) {
344         // `_handleTokenError` always throws, this syntax keeps the linter happy.
345         throw await this._fxai._handleTokenError(err);
346       }
347     });
348   }
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()
356     ).sort();
357     return (
358       !device ||
359       !device.registrationVersion ||
360       device.registrationVersion < this.DEVICE_REGISTRATION_VERSION ||
361       !device.registeredCommandsKeys ||
362       !CommonUtils.arrayEqual(
363         device.registeredCommandsKeys,
364         availableCommandsKeys
365       )
366     );
367   }
369   async _checkRemoteCommandsUpdateNeeded(remoteAvailableCommands) {
370     if (!remoteAvailableCommands) {
371       return true;
372     }
373     const remoteAvailableCommandsKeys = Object.keys(
374       remoteAvailableCommands
375     ).sort();
376     const localAvailableCommands = await this._fxai.commands.availableCommands();
377     const localAvailableCommandsKeys = Object.keys(
378       localAvailableCommands
379     ).sort();
381     if (
382       !CommonUtils.arrayEqual(
383         localAvailableCommandsKeys,
384         remoteAvailableCommandsKeys
385       )
386     ) {
387       return true;
388     }
390     for (const key of localAvailableCommandsKeys) {
391       if (remoteAvailableCommands[key] !== localAvailableCommands[key]) {
392         return true;
393       }
394     }
395     return false;
396   }
398   async _updateDeviceRegistrationIfNecessary(currentState) {
399     let data = await currentState.getUserAccountData([
400       "sessionToken",
401       "device",
402     ]);
403     if (!data) {
404       // Can't register a device without a signed-in user.
405       return null;
406     }
407     const { device } = data;
408     if (await this._checkDeviceUpdateNeeded(device)) {
409       return this._registerOrUpdateDevice(currentState, data);
410     }
411     // Return the device ID we already had.
412     return device.id;
413   }
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) {
423       throw new Error(
424         "_registerOrUpdateDevice called after a different user has signed in"
425       );
426     }
428     const { sessionToken, device: currentDevice } = signedInUser;
429     if (!sessionToken) {
430       throw new Error("_registerOrUpdateDevice called without a session token");
431     }
433     try {
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);
446         }
447       }
448       deviceOptions.availableCommands = await this._fxai.commands.availableCommands();
449       const availableCommandsKeys = Object.keys(
450         deviceOptions.availableCommands
451       ).sort();
452       log.info("registering with available commands", availableCommandsKeys);
454       let device;
455       if (currentDevice && currentDevice.id) {
456         log.debug("updating existing device details");
457         device = await this._fxai.fxAccountsClient.updateDevice(
458           sessionToken,
459           currentDevice.id,
460           deviceName,
461           deviceOptions
462         );
463       } else {
464         log.debug("registering new device details");
465         device = await this._fxai.fxAccountsClient.registerDevice(
466           sessionToken,
467           deviceName,
468           this.getLocalType(),
469           deviceOptions
470         );
471         Services.obs.notifyObservers(null, ON_NEW_DEVICE_ID);
472       }
474       // Get the freshest device props before updating them.
475       let { device: deviceProps } = await currentState.getUserAccountData([
476         "device",
477       ]);
478       await currentState.updateUserAccountData({
479         device: {
480           ...deviceProps, // Copy the other properties (e.g. handledCommands).
481           id: device.id,
482           registrationVersion: this.DEVICE_REGISTRATION_VERSION,
483           registeredCommandsKeys: availableCommandsKeys,
484         },
485       });
486       return device.id;
487     } catch (error) {
488       return this._handleDeviceError(currentState, error, sessionToken);
489     }
490   }
492   async _handleDeviceError(currentState, error, sessionToken) {
493     try {
494       if (error.code === 400) {
495         if (error.errno === ERRNO_UNKNOWN_DEVICE) {
496           return this._recoverFromUnknownDevice(currentState);
497         }
499         if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) {
500           return this._recoverFromDeviceSessionConflict(
501             currentState,
502             error,
503             sessionToken
504           );
505         }
506       }
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);
513     } catch (error) {
514       await this._logErrorAndResetDeviceRegistrationVersion(
515         currentState,
516         error
517       );
518       return null;
519     }
520   }
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");
527     try {
528       await currentState.updateUserAccountData({
529         device: null,
530         encryptedSendTabKeys: null,
531       });
532     } catch (error) {
533       await this._logErrorAndResetDeviceRegistrationVersion(
534         currentState,
535         error
536       );
537     }
538     return null;
539   }
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.
550     log.warn(
551       "device session conflict, attempting to ascertain the correct device id"
552     );
553     try {
554       const devices = await this._fxai.fxAccountsClient.getDeviceList(
555         sessionToken
556       );
557       const matchingDevices = devices.filter(device => device.isCurrentDevice);
558       const length = matchingDevices.length;
559       if (length === 1) {
560         const deviceId = matchingDevices[0].id;
561         await currentState.updateUserAccountData({
562           device: {
563             id: deviceId,
564             registrationVersion: null,
565           },
566           encryptedSendTabKeys: null,
567         });
568         return deviceId;
569       }
570       if (length > 1) {
571         log.error(
572           "insane server state, " + length + " devices for this session"
573         );
574       }
575       await this._logErrorAndResetDeviceRegistrationVersion(
576         currentState,
577         error
578       );
579     } catch (secondError) {
580       log.error("failed to recover from device-session conflict", secondError);
581       await this._logErrorAndResetDeviceRegistrationVersion(
582         currentState,
583         error
584       );
585     }
586     return null;
587   }
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);
595     try {
596       await currentState.updateUserAccountData({
597         device: null,
598         encryptedSendTabKeys: null,
599       });
600     } catch (secondError) {
601       log.error(
602         "failed to reset the device registration version, device registration won't be retried",
603         secondError
604       );
605     }
606   }
608   // Kick off a background refresh when a device is connected or disconnected.
609   observe(subject, topic, data) {
610     switch (topic) {
611       case ON_DEVICE_CONNECTED_NOTIFICATION:
612         this.refreshDeviceList({ ignoreCached: true }).catch(error => {
613           log.warn(
614             "failed to refresh devices after connecting a new device",
615             error
616           );
617         });
618         break;
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 => {
625             log.warn(
626               "failed to refresh devices after disconnecting a device",
627               error
628             );
629           });
630         }
631         break;
632       case ONVERIFIED_NOTIFICATION:
633         this.updateDeviceRegistrationIfNecessary().catch(error => {
634           log.warn(
635             "updateDeviceRegistrationIfNecessary failed after verification",
636             error
637           );
638         });
639         break;
640     }
641   }
644 FxAccountsDevice.prototype.QueryInterface = ChromeUtils.generateQI([
645   "nsIObserver",
646   "nsISupportsWeakReference",
649 function urlsafeBase64Encode(buffer) {
650   return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
653 var EXPORTED_SYMBOLS = ["FxAccountsDevice"];