Bug 1845134 - Part 4: Update existing ui-icons to use the latest source from acorn...
[gecko.git] / services / fxaccounts / FxAccountsDevice.sys.mjs
blob6b2089739c616565d0e07c13e05e8e2169b17fd9
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/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
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";
19 import { DEVICE_TYPE_DESKTOP } from "resource://services-sync/constants.sys.mjs";
21 const lazy = {};
23 ChromeUtils.defineESModuleGetters(lazy, {
24   CommonUtils: "resource://services-common/utils.sys.mjs",
25 });
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   ""
35 const PREF_DEPRECATED_DEVICE_NAME = "services.sync.client.name";
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";
47 function sanitizeDeviceName(name) {
48   return name
49     .substr(0, MAX_NAME_LEN)
50     .replace(INVALID_NAME_CHARS, REPLACEMENT_CHAR);
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;
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;
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
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   }
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   }
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.
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     }
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;
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   }
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   }
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   }
159   getLocalType() {
160     return DEVICE_TYPE_DESKTOP;
161   }
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   }
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     }
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     }
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             );
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   }
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   }
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   }
287   async updateDeviceRegistrationIfNecessary() {
288     return this._withCurrentAccountState(currentState => {
289       return this._updateDeviceRegistrationIfNecessary(currentState);
290     });
291   }
293   reset() {
294     this._deviceListCache = null;
295     this._fetchAndCacheDeviceListPromise = null;
296   }
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    */
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   }
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   }
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   }
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();
364     if (
365       !lazy.CommonUtils.arrayEqual(
366         localAvailableCommandsKeys,
367         remoteAvailableCommandsKeys
368       )
369     ) {
370       return true;
371     }
373     for (const key of localAvailableCommandsKeys) {
374       if (remoteAvailableCommands[key] !== localAvailableCommands[key]) {
375         return true;
376       }
377     }
378     return false;
379   }
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   }
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     }
411     const { sessionToken, device: currentDevice } = signedInUser;
412     if (!sessionToken) {
413       throw new Error("_registerOrUpdateDevice called without a session token");
414     }
416     try {
417       const subscription =
418         await this._fxai.fxaPushService.registerPushEndpoint();
419       const deviceName = this.getLocalName();
420       let deviceOptions = {};
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);
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       }
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   }
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         }
488         if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) {
489           return this._recoverFromDeviceSessionConflict(
490             currentState,
491             error,
492             sessionToken
493           );
494         }
495       }
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   }
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   }
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 = await this._fxai.fxAccountsClient.getDeviceList(
544         sessionToken
545       );
546       const matchingDevices = devices.filter(device => device.isCurrentDevice);
547       const length = matchingDevices.length;
548       if (length === 1) {
549         const deviceId = matchingDevices[0].id;
550         await currentState.updateUserAccountData({
551           device: {
552             id: deviceId,
553             registrationVersion: null,
554           },
555           encryptedSendTabKeys: null,
556         });
557         return deviceId;
558       }
559       if (length > 1) {
560         log.error(
561           "insane server state, " + length + " devices for this session"
562         );
563       }
564       await this._logErrorAndResetDeviceRegistrationVersion(
565         currentState,
566         error
567       );
568     } catch (secondError) {
569       log.error("failed to recover from device-session conflict", secondError);
570       await this._logErrorAndResetDeviceRegistrationVersion(
571         currentState,
572         error
573       );
574     }
575     return null;
576   }
578   async _logErrorAndResetDeviceRegistrationVersion(currentState, error) {
579     // Device registration should never cause other operations to fail.
580     // If we've reached this point, just log the error and reset the device
581     // on the account data. At next sync or next sign-in,
582     // registration will be retried.
583     log.error("device registration failed", error);
584     try {
585       await currentState.updateUserAccountData({
586         device: null,
587         encryptedSendTabKeys: null,
588       });
589     } catch (secondError) {
590       log.error(
591         "failed to reset the device registration version, device registration won't be retried",
592         secondError
593       );
594     }
595   }
597   // Kick off a background refresh when a device is connected or disconnected.
598   observe(subject, topic, data) {
599     switch (topic) {
600       case ON_DEVICE_CONNECTED_NOTIFICATION:
601         this.refreshDeviceList({ ignoreCached: true }).catch(error => {
602           log.warn(
603             "failed to refresh devices after connecting a new device",
604             error
605           );
606         });
607         break;
608       case ON_DEVICE_DISCONNECTED_NOTIFICATION:
609         let json = JSON.parse(data);
610         if (!json.isLocalDevice) {
611           // If we're the device being disconnected, don't bother fetching a new
612           // list, since our session token is now invalid.
613           this.refreshDeviceList({ ignoreCached: true }).catch(error => {
614             log.warn(
615               "failed to refresh devices after disconnecting a device",
616               error
617             );
618           });
619         }
620         break;
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   }
633 FxAccountsDevice.prototype.QueryInterface = ChromeUtils.generateQI([
634   "nsIObserver",
635   "nsISupportsWeakReference",
638 function urlsafeBase64Encode(buffer) {
639   return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });