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";
9 ERRNO_DEVICE_SESSION_CONFLICT,
12 ON_DEVICELIST_UPDATED,
13 ON_DEVICE_CONNECTED_NOTIFICATION,
14 ON_DEVICE_DISCONNECTED_NOTIFICATION,
15 ONVERIFIED_NOTIFICATION,
17 } = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
19 import { DEVICE_TYPE_DESKTOP } from "resource://services-sync/constants.sys.mjs";
23 ChromeUtils.defineESModuleGetters(lazy, {
24 CommonUtils: "resource://services-common/utils.sys.mjs",
27 const PREF_LOCAL_DEVICE_NAME = PREF_ACCOUNT_ROOT + "device.name";
28 XPCOMUtils.defineLazyPreferenceGetter(
30 "pref_localDeviceName",
31 PREF_LOCAL_DEVICE_NAME,
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) {
49 .substr(0, MAX_NAME_LEN)
50 .replace(INVALID_NAME_CHARS, REPLACEMENT_CHAR);
53 // Everything to do with FxA devices.
54 export class FxAccountsDevice {
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);
77 return this._withCurrentAccountState(currentState => {
78 // It turns out _updateDeviceRegistrationIfNecessary() does exactly what we
80 return this._updateDeviceRegistrationIfNecessary(currentState);
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");
99 // The DNS service may fail to provide a hostname in edge-cases we don't
100 // fully understand - bug 1391488.
103 // hostname of the system, usually assigned by the user or admin
104 hostname = Services.dns.myHostName;
109 // 'device' is defined on unix systems
110 Services.sysinfo.get("device") ||
112 // fall back on ua info string
113 Cc["@mozilla.org/network/protocol;1?name=http"].getService(
114 Ci.nsIHttpProtocolHandler
117 const l10n = new Localization(
118 ["services/accounts.ftl", "branding/brand.ftl"],
121 return sanitizeDeviceName(
122 l10n.formatValueSync("account-client-name", { user, system })
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,
133 if (deprecated_value) {
134 Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, deprecated_value);
135 Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME);
137 let name = lazy.pref_localDeviceName;
139 name = this.getDefaultLocalName();
140 Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, name);
142 // We need to sanitize here because some names were generated before we
143 // started sanitizing.
144 return sanitizeDeviceName(name);
147 setLocalName(newName) {
148 Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME);
149 Services.prefs.setStringPref(
150 PREF_LOCAL_DEVICE_NAME,
151 sanitizeDeviceName(newName)
153 // Update the registration in the background.
154 this.updateDeviceRegistration().catch(error => {
155 log.warn("failed to update fxa device registration", error);
160 return DEVICE_TYPE_DESKTOP;
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
171 get recentDeviceList() {
172 return this._deviceListCache ? this._deviceListCache.devices : null;
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
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
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;
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");
205 log.info("fetching updated device list");
206 this._fetchAndCacheDeviceListPromise = (async () => {
208 const devices = await this._withVerifiedAccountState(
209 async currentState => {
210 const accountData = await currentState.getUserAccountData([
214 const devices = await this._fxai.fxAccountsClient.getDeviceList(
215 accountData.sessionToken
218 `Got new device list: ${devices.map(d => d.id).join(", ")}`
221 await this._refreshRemoteDevice(currentState, accountData, devices);
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(),
232 Services.obs.notifyObservers(null, ON_DEVICELIST_UPDATED);
235 this._fetchAndCacheDeviceListPromise = null;
238 return this._fetchAndCacheDeviceListPromise;
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();
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.
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();
266 (await this._checkRemoteCommandsUpdateNeeded(ourDevice.availableCommands))
268 log.warn(`Our commands need to be updated on the server`);
269 await this._registerOrUpdateDevice(currentState, accountData);
271 log.trace(`Our push subscription looks OK`);
275 async updateDeviceRegistration() {
276 return this._withCurrentAccountState(async currentState => {
277 const signedInUser = await currentState.getUserAccountData([
282 await this._registerOrUpdateDevice(currentState, signedInUser);
287 async updateDeviceRegistrationIfNecessary() {
288 return this._withCurrentAccountState(currentState => {
289 return this._updateDeviceRegistrationIfNecessary(currentState);
294 this._deviceListCache = null;
295 this._fetchAndCacheDeviceListPromise = null;
299 * Here begin our internal helper methods.
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.
308 _withCurrentAccountState(func) {
309 return this._fxai.withCurrentAccountState(async currentState => {
311 return await func(currentState);
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);
321 _withVerifiedAccountState(func) {
322 return this._fxai.withVerifiedAccountState(async currentState => {
324 return await func(currentState);
326 // `_handleTokenError` always throws, this syntax keeps the linter happy.
327 throw await this._fxai._handleTokenError(err);
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()
341 !device.registrationVersion ||
342 device.registrationVersion < this.DEVICE_REGISTRATION_VERSION ||
343 !device.registeredCommandsKeys ||
344 !lazy.CommonUtils.arrayEqual(
345 device.registeredCommandsKeys,
346 availableCommandsKeys
351 async _checkRemoteCommandsUpdateNeeded(remoteAvailableCommands) {
352 if (!remoteAvailableCommands) {
355 const remoteAvailableCommandsKeys = Object.keys(
356 remoteAvailableCommands
358 const localAvailableCommands =
359 await this._fxai.commands.availableCommands();
360 const localAvailableCommandsKeys = Object.keys(
361 localAvailableCommands
365 !lazy.CommonUtils.arrayEqual(
366 localAvailableCommandsKeys,
367 remoteAvailableCommandsKeys
373 for (const key of localAvailableCommandsKeys) {
374 if (remoteAvailableCommands[key] !== localAvailableCommands[key]) {
381 async _updateDeviceRegistrationIfNecessary(currentState) {
382 let data = await currentState.getUserAccountData([
387 // Can't register a device without a signed-in user.
390 const { device } = data;
391 if (await this._checkDeviceUpdateNeeded(device)) {
392 return this._registerOrUpdateDevice(currentState, data);
394 // Return the device ID we already had.
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) {
407 "_registerOrUpdateDevice called after a different user has signed in"
411 const { sessionToken, device: currentDevice } = signedInUser;
413 throw new Error("_registerOrUpdateDevice called without a session token");
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);
432 deviceOptions.availableCommands =
433 await this._fxai.commands.availableCommands();
434 const availableCommandsKeys = Object.keys(
435 deviceOptions.availableCommands
437 log.info("registering with available commands", availableCommandsKeys);
440 let is_existing = currentDevice && currentDevice.id;
442 log.debug("updating existing device details");
443 device = await this._fxai.fxAccountsClient.updateDevice(
450 log.debug("registering new device details");
451 device = await this._fxai.fxAccountsClient.registerDevice(
459 // Get the freshest device props before updating them.
460 let { device: deviceProps } = await currentState.getUserAccountData([
463 await currentState.updateUserAccountData({
465 ...deviceProps, // Copy the other properties (e.g. handledCommands).
467 registrationVersion: this.DEVICE_REGISTRATION_VERSION,
468 registeredCommandsKeys: availableCommandsKeys,
471 // Must send the notification after we've written the storage.
473 Services.obs.notifyObservers(null, ON_NEW_DEVICE_ID);
477 return this._handleDeviceError(currentState, error, sessionToken);
481 async _handleDeviceError(currentState, error, sessionToken) {
483 if (error.code === 400) {
484 if (error.errno === ERRNO_UNKNOWN_DEVICE) {
485 return this._recoverFromUnknownDevice(currentState);
488 if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) {
489 return this._recoverFromDeviceSessionConflict(
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);
503 await this._logErrorAndResetDeviceRegistrationVersion(
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");
517 await currentState.updateUserAccountData({
519 encryptedSendTabKeys: null,
522 await this._logErrorAndResetDeviceRegistrationVersion(
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.
540 "device session conflict, attempting to ascertain the correct device id"
543 const devices = await this._fxai.fxAccountsClient.getDeviceList(
546 const matchingDevices = devices.filter(device => device.isCurrentDevice);
547 const length = matchingDevices.length;
549 const deviceId = matchingDevices[0].id;
550 await currentState.updateUserAccountData({
553 registrationVersion: null,
555 encryptedSendTabKeys: null,
561 "insane server state, " + length + " devices for this session"
564 await this._logErrorAndResetDeviceRegistrationVersion(
568 } catch (secondError) {
569 log.error("failed to recover from device-session conflict", secondError);
570 await this._logErrorAndResetDeviceRegistrationVersion(
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);
585 await currentState.updateUserAccountData({
587 encryptedSendTabKeys: null,
589 } catch (secondError) {
591 "failed to reset the device registration version, device registration won't be retried",
597 // Kick off a background refresh when a device is connected or disconnected.
598 observe(subject, topic, data) {
600 case ON_DEVICE_CONNECTED_NOTIFICATION:
601 this.refreshDeviceList({ ignoreCached: true }).catch(error => {
603 "failed to refresh devices after connecting a new device",
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 => {
615 "failed to refresh devices after disconnecting a device",
621 case ONVERIFIED_NOTIFICATION:
622 this.updateDeviceRegistrationIfNecessary().catch(error => {
624 "updateDeviceRegistrationIfNecessary failed after verification",
633 FxAccountsDevice.prototype.QueryInterface = ChromeUtils.generateQI([
635 "nsISupportsWeakReference",
638 function urlsafeBase64Encode(buffer) {
639 return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });