diff options
Diffstat (limited to 'services/fxaccounts/FxAccountsDevice.sys.mjs')
-rw-r--r-- | services/fxaccounts/FxAccountsDevice.sys.mjs | 640 |
1 files changed, 640 insertions, 0 deletions
diff --git a/services/fxaccounts/FxAccountsDevice.sys.mjs b/services/fxaccounts/FxAccountsDevice.sys.mjs new file mode 100644 index 0000000000..6b2089739c --- /dev/null +++ b/services/fxaccounts/FxAccountsDevice.sys.mjs @@ -0,0 +1,640 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { + log, + ERRNO_DEVICE_SESSION_CONFLICT, + ERRNO_UNKNOWN_DEVICE, + ON_NEW_DEVICE_ID, + ON_DEVICELIST_UPDATED, + ON_DEVICE_CONNECTED_NOTIFICATION, + ON_DEVICE_DISCONNECTED_NOTIFICATION, + ONVERIFIED_NOTIFICATION, + PREF_ACCOUNT_ROOT, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +import { DEVICE_TYPE_DESKTOP } from "resource://services-sync/constants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CommonUtils: "resource://services-common/utils.sys.mjs", +}); + +const PREF_LOCAL_DEVICE_NAME = PREF_ACCOUNT_ROOT + "device.name"; +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "pref_localDeviceName", + PREF_LOCAL_DEVICE_NAME, + "" +); + +const PREF_DEPRECATED_DEVICE_NAME = "services.sync.client.name"; + +// Sanitizes all characters which the FxA server considers invalid, replacing +// them with the unicode replacement character. +// At time of writing, FxA has a regex DISPLAY_SAFE_UNICODE_WITH_NON_BMP, which +// the regex below is based on. +const INVALID_NAME_CHARS = + // eslint-disable-next-line no-control-regex + /[\u0000-\u001F\u007F\u0080-\u009F\u2028-\u2029\uE000-\uF8FF\uFFF9-\uFFFC\uFFFE-\uFFFF]/g; +const MAX_NAME_LEN = 255; +const REPLACEMENT_CHAR = "\uFFFD"; + +function sanitizeDeviceName(name) { + return name + .substr(0, MAX_NAME_LEN) + .replace(INVALID_NAME_CHARS, REPLACEMENT_CHAR); +} + +// Everything to do with FxA devices. +export class FxAccountsDevice { + constructor(fxai) { + this._fxai = fxai; + this._deviceListCache = null; + this._fetchAndCacheDeviceListPromise = null; + + // The current version of the device registration, we use this to re-register + // devices after we update what we send on device registration. + this.DEVICE_REGISTRATION_VERSION = 2; + + // This is to avoid multiple sequential syncs ending up calling + // this expensive endpoint multiple times in a row. + this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS = 1 * 60 * 1000; // 1 minute + + // Invalidate our cached device list when a device is connected or disconnected. + Services.obs.addObserver(this, ON_DEVICE_CONNECTED_NOTIFICATION, true); + Services.obs.addObserver(this, ON_DEVICE_DISCONNECTED_NOTIFICATION, true); + // A user becoming verified probably means we need to re-register the device + // because we are now able to get the sendtab keys. + Services.obs.addObserver(this, ONVERIFIED_NOTIFICATION, true); + } + + async getLocalId() { + return this._withCurrentAccountState(currentState => { + // It turns out _updateDeviceRegistrationIfNecessary() does exactly what we + // need. + return this._updateDeviceRegistrationIfNecessary(currentState); + }); + } + + // Generate a client name if we don't have a useful one yet + getDefaultLocalName() { + let user = Services.env.get("USER") || Services.env.get("USERNAME"); + // Note that we used to fall back to the "services.sync.username" pref here, + // but that's no longer suitable in a world where sync might not be + // configured. However, we almost never *actually* fell back to that, and + // doing so sanely here would mean making this function async, which we don't + // really want to do yet. + + // A little hack for people using the the moz-build environment on Windows + // which sets USER to the literal "%USERNAME%" (yes, really) + if (user == "%USERNAME%" && Services.env.get("USERNAME")) { + user = Services.env.get("USERNAME"); + } + + // The DNS service may fail to provide a hostname in edge-cases we don't + // fully understand - bug 1391488. + let hostname; + try { + // hostname of the system, usually assigned by the user or admin + hostname = Services.dns.myHostName; + } catch (ex) { + console.error(ex); + } + let system = + // 'device' is defined on unix systems + Services.sysinfo.get("device") || + hostname || + // fall back on ua info string + Cc["@mozilla.org/network/protocol;1?name=http"].getService( + Ci.nsIHttpProtocolHandler + ).oscpu; + + const l10n = new Localization( + ["services/accounts.ftl", "branding/brand.ftl"], + true + ); + return sanitizeDeviceName( + l10n.formatValueSync("account-client-name", { user, system }) + ); + } + + getLocalName() { + // We used to store this in services.sync.client.name, but now store it + // under an fxa-specific location. + let deprecated_value = Services.prefs.getStringPref( + PREF_DEPRECATED_DEVICE_NAME, + "" + ); + if (deprecated_value) { + Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, deprecated_value); + Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME); + } + let name = lazy.pref_localDeviceName; + if (!name) { + name = this.getDefaultLocalName(); + Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, name); + } + // We need to sanitize here because some names were generated before we + // started sanitizing. + return sanitizeDeviceName(name); + } + + setLocalName(newName) { + Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME); + Services.prefs.setStringPref( + PREF_LOCAL_DEVICE_NAME, + sanitizeDeviceName(newName) + ); + // Update the registration in the background. + this.updateDeviceRegistration().catch(error => { + log.warn("failed to update fxa device registration", error); + }); + } + + getLocalType() { + return DEVICE_TYPE_DESKTOP; + } + + /** + * Returns the most recently fetched device list, or `null` if the list + * hasn't been fetched yet. This is synchronous, so that consumers like + * Send Tab can render the device list right away, without waiting for + * it to refresh. + * + * @type {?Array} + */ + get recentDeviceList() { + return this._deviceListCache ? this._deviceListCache.devices : null; + } + + /** + * Refreshes the device list. After this function returns, consumers can + * access the new list using the `recentDeviceList` getter. Note that + * multiple concurrent calls to `refreshDeviceList` will only refresh the + * list once. + * + * @param {Boolean} [options.ignoreCached] + * If `true`, forces a refresh, even if the cached device list is + * still fresh. Defaults to `false`. + * @return {Promise<Boolean>} + * `true` if the list was refreshed, `false` if the cached list is + * fresh. Rejects if an error occurs refreshing the list or device + * push registration. + */ + async refreshDeviceList({ ignoreCached = false } = {}) { + // If we're already refreshing the list in the background, let that finish. + if (this._fetchAndCacheDeviceListPromise) { + log.info("Already fetching device list, return existing promise"); + return this._fetchAndCacheDeviceListPromise; + } + + // If the cache is fresh enough, don't refresh it again. + if (!ignoreCached && this._deviceListCache) { + const ageOfCache = this._fxai.now() - this._deviceListCache.lastFetch; + if (ageOfCache < this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS) { + log.info("Device list cache is fresh, re-using it"); + return false; + } + } + + log.info("fetching updated device list"); + this._fetchAndCacheDeviceListPromise = (async () => { + try { + const devices = await this._withVerifiedAccountState( + async currentState => { + const accountData = await currentState.getUserAccountData([ + "sessionToken", + "device", + ]); + const devices = await this._fxai.fxAccountsClient.getDeviceList( + accountData.sessionToken + ); + log.info( + `Got new device list: ${devices.map(d => d.id).join(", ")}` + ); + + await this._refreshRemoteDevice(currentState, accountData, devices); + return devices; + } + ); + log.info("updating the cache"); + // Be careful to only update the cache once the above has resolved, so + // we know that the current account state didn't change underneath us. + this._deviceListCache = { + lastFetch: this._fxai.now(), + devices, + }; + Services.obs.notifyObservers(null, ON_DEVICELIST_UPDATED); + return true; + } finally { + this._fetchAndCacheDeviceListPromise = null; + } + })(); + return this._fetchAndCacheDeviceListPromise; + } + + async _refreshRemoteDevice(currentState, accountData, remoteDevices) { + // Check if our push registration previously succeeded and is still + // good (although background device registration means it's possible + // we'll be fetching the device list before we've actually + // registered ourself!) + // (For a missing subscription we check for an explicit 'null' - + // both to help tests and as a safety valve - missing might mean + // "no push available" for self-hosters or similar?) + const ourDevice = remoteDevices.find(device => device.isCurrentDevice); + const subscription = await this._fxai.fxaPushService.getSubscription(); + if ( + ourDevice && + (ourDevice.pushCallback === null || // fxa server doesn't know our subscription. + ourDevice.pushEndpointExpired || // fxa server thinks it has expired. + !subscription || // we don't have a local subscription. + subscription.isExpired() || // our local subscription is expired. + ourDevice.pushCallback != subscription.endpoint) // we don't agree with fxa. + ) { + log.warn(`Our push endpoint needs resubscription`); + await this._fxai.fxaPushService.unsubscribe(); + await this._registerOrUpdateDevice(currentState, accountData); + // and there's a reasonable chance there are commands waiting. + await this._fxai.commands.pollDeviceCommands(); + } else if ( + ourDevice && + (await this._checkRemoteCommandsUpdateNeeded(ourDevice.availableCommands)) + ) { + log.warn(`Our commands need to be updated on the server`); + await this._registerOrUpdateDevice(currentState, accountData); + } else { + log.trace(`Our push subscription looks OK`); + } + } + + async updateDeviceRegistration() { + return this._withCurrentAccountState(async currentState => { + const signedInUser = await currentState.getUserAccountData([ + "sessionToken", + "device", + ]); + if (signedInUser) { + await this._registerOrUpdateDevice(currentState, signedInUser); + } + }); + } + + async updateDeviceRegistrationIfNecessary() { + return this._withCurrentAccountState(currentState => { + return this._updateDeviceRegistrationIfNecessary(currentState); + }); + } + + reset() { + this._deviceListCache = null; + this._fetchAndCacheDeviceListPromise = null; + } + + /** + * Here begin our internal helper methods. + * + * Many of these methods take the current account state as first argument, + * in order to avoid racing our state updates with e.g. the uer signing + * out while we're in the middle of an update. If this does happen, the + * resulting promise will be rejected rather than persisting stale state. + * + */ + + _withCurrentAccountState(func) { + return this._fxai.withCurrentAccountState(async currentState => { + try { + return await func(currentState); + } catch (err) { + // `_handleTokenError` always throws, this syntax keeps the linter happy. + // TODO: probably `_handleTokenError` could be done by `_fxai.withCurrentAccountState` + // internally rather than us having to remember to do it here. + throw await this._fxai._handleTokenError(err); + } + }); + } + + _withVerifiedAccountState(func) { + return this._fxai.withVerifiedAccountState(async currentState => { + try { + return await func(currentState); + } catch (err) { + // `_handleTokenError` always throws, this syntax keeps the linter happy. + throw await this._fxai._handleTokenError(err); + } + }); + } + + async _checkDeviceUpdateNeeded(device) { + // There is no device registered or the device registration is outdated. + // Either way, we should register the device with FxA + // before returning the id to the caller. + const availableCommandsKeys = Object.keys( + await this._fxai.commands.availableCommands() + ).sort(); + return ( + !device || + !device.registrationVersion || + device.registrationVersion < this.DEVICE_REGISTRATION_VERSION || + !device.registeredCommandsKeys || + !lazy.CommonUtils.arrayEqual( + device.registeredCommandsKeys, + availableCommandsKeys + ) + ); + } + + async _checkRemoteCommandsUpdateNeeded(remoteAvailableCommands) { + if (!remoteAvailableCommands) { + return true; + } + const remoteAvailableCommandsKeys = Object.keys( + remoteAvailableCommands + ).sort(); + const localAvailableCommands = + await this._fxai.commands.availableCommands(); + const localAvailableCommandsKeys = Object.keys( + localAvailableCommands + ).sort(); + + if ( + !lazy.CommonUtils.arrayEqual( + localAvailableCommandsKeys, + remoteAvailableCommandsKeys + ) + ) { + return true; + } + + for (const key of localAvailableCommandsKeys) { + if (remoteAvailableCommands[key] !== localAvailableCommands[key]) { + return true; + } + } + return false; + } + + async _updateDeviceRegistrationIfNecessary(currentState) { + let data = await currentState.getUserAccountData([ + "sessionToken", + "device", + ]); + if (!data) { + // Can't register a device without a signed-in user. + return null; + } + const { device } = data; + if (await this._checkDeviceUpdateNeeded(device)) { + return this._registerOrUpdateDevice(currentState, data); + } + // Return the device ID we already had. + return device.id; + } + + // If you change what we send to the FxA servers during device registration, + // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older + // devices to re-register when Firefox updates. + async _registerOrUpdateDevice(currentState, signedInUser) { + // This method has the side-effect of setting some account-related prefs + // (e.g. for caching the device name) so it's important we don't execute it + // if the signed-in state has changed. + if (!currentState.isCurrent) { + throw new Error( + "_registerOrUpdateDevice called after a different user has signed in" + ); + } + + const { sessionToken, device: currentDevice } = signedInUser; + if (!sessionToken) { + throw new Error("_registerOrUpdateDevice called without a session token"); + } + + try { + const subscription = + await this._fxai.fxaPushService.registerPushEndpoint(); + const deviceName = this.getLocalName(); + let deviceOptions = {}; + + // if we were able to obtain a subscription + if (subscription && subscription.endpoint) { + deviceOptions.pushCallback = subscription.endpoint; + let publicKey = subscription.getKey("p256dh"); + let authKey = subscription.getKey("auth"); + if (publicKey && authKey) { + deviceOptions.pushPublicKey = urlsafeBase64Encode(publicKey); + deviceOptions.pushAuthKey = urlsafeBase64Encode(authKey); + } + } + deviceOptions.availableCommands = + await this._fxai.commands.availableCommands(); + const availableCommandsKeys = Object.keys( + deviceOptions.availableCommands + ).sort(); + log.info("registering with available commands", availableCommandsKeys); + + let device; + let is_existing = currentDevice && currentDevice.id; + if (is_existing) { + log.debug("updating existing device details"); + device = await this._fxai.fxAccountsClient.updateDevice( + sessionToken, + currentDevice.id, + deviceName, + deviceOptions + ); + } else { + log.debug("registering new device details"); + device = await this._fxai.fxAccountsClient.registerDevice( + sessionToken, + deviceName, + this.getLocalType(), + deviceOptions + ); + } + + // Get the freshest device props before updating them. + let { device: deviceProps } = await currentState.getUserAccountData([ + "device", + ]); + await currentState.updateUserAccountData({ + device: { + ...deviceProps, // Copy the other properties (e.g. handledCommands). + id: device.id, + registrationVersion: this.DEVICE_REGISTRATION_VERSION, + registeredCommandsKeys: availableCommandsKeys, + }, + }); + // Must send the notification after we've written the storage. + if (!is_existing) { + Services.obs.notifyObservers(null, ON_NEW_DEVICE_ID); + } + return device.id; + } catch (error) { + return this._handleDeviceError(currentState, error, sessionToken); + } + } + + async _handleDeviceError(currentState, error, sessionToken) { + try { + if (error.code === 400) { + if (error.errno === ERRNO_UNKNOWN_DEVICE) { + return this._recoverFromUnknownDevice(currentState); + } + + if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) { + return this._recoverFromDeviceSessionConflict( + currentState, + error, + sessionToken + ); + } + } + + // `_handleTokenError` always throws, this syntax keeps the linter happy. + // Note that the re-thrown error is immediately caught, logged and ignored + // by the containing scope here, which is why we have to `_handleTokenError` + // ourselves rather than letting it bubble up for handling by the caller. + throw await this._fxai._handleTokenError(error); + } catch (error) { + await this._logErrorAndResetDeviceRegistrationVersion( + currentState, + error + ); + return null; + } + } + + async _recoverFromUnknownDevice(currentState) { + // FxA did not recognise the device id. Handle it by clearing the device + // id on the account data. At next sync or next sign-in, registration is + // retried and should succeed. + log.warn("unknown device id, clearing the local device data"); + try { + await currentState.updateUserAccountData({ + device: null, + encryptedSendTabKeys: null, + }); + } catch (error) { + await this._logErrorAndResetDeviceRegistrationVersion( + currentState, + error + ); + } + return null; + } + + async _recoverFromDeviceSessionConflict(currentState, error, sessionToken) { + // FxA has already associated this session with a different device id. + // Perhaps we were beaten in a race to register. Handle the conflict: + // 1. Fetch the list of devices for the current user from FxA. + // 2. Look for ourselves in the list. + // 3. If we find a match, set the correct device id and device registration + // version on the account data and return the correct device id. At next + // sync or next sign-in, registration is retried and should succeed. + // 4. If we don't find a match, log the original error. + log.warn( + "device session conflict, attempting to ascertain the correct device id" + ); + try { + const devices = await this._fxai.fxAccountsClient.getDeviceList( + sessionToken + ); + const matchingDevices = devices.filter(device => device.isCurrentDevice); + const length = matchingDevices.length; + if (length === 1) { + const deviceId = matchingDevices[0].id; + await currentState.updateUserAccountData({ + device: { + id: deviceId, + registrationVersion: null, + }, + encryptedSendTabKeys: null, + }); + return deviceId; + } + if (length > 1) { + log.error( + "insane server state, " + length + " devices for this session" + ); + } + await this._logErrorAndResetDeviceRegistrationVersion( + currentState, + error + ); + } catch (secondError) { + log.error("failed to recover from device-session conflict", secondError); + await this._logErrorAndResetDeviceRegistrationVersion( + currentState, + error + ); + } + return null; + } + + async _logErrorAndResetDeviceRegistrationVersion(currentState, error) { + // Device registration should never cause other operations to fail. + // If we've reached this point, just log the error and reset the device + // on the account data. At next sync or next sign-in, + // registration will be retried. + log.error("device registration failed", error); + try { + await currentState.updateUserAccountData({ + device: null, + encryptedSendTabKeys: null, + }); + } catch (secondError) { + log.error( + "failed to reset the device registration version, device registration won't be retried", + secondError + ); + } + } + + // Kick off a background refresh when a device is connected or disconnected. + observe(subject, topic, data) { + switch (topic) { + case ON_DEVICE_CONNECTED_NOTIFICATION: + this.refreshDeviceList({ ignoreCached: true }).catch(error => { + log.warn( + "failed to refresh devices after connecting a new device", + error + ); + }); + break; + case ON_DEVICE_DISCONNECTED_NOTIFICATION: + let json = JSON.parse(data); + if (!json.isLocalDevice) { + // If we're the device being disconnected, don't bother fetching a new + // list, since our session token is now invalid. + this.refreshDeviceList({ ignoreCached: true }).catch(error => { + log.warn( + "failed to refresh devices after disconnecting a device", + error + ); + }); + } + break; + case ONVERIFIED_NOTIFICATION: + this.updateDeviceRegistrationIfNecessary().catch(error => { + log.warn( + "updateDeviceRegistrationIfNecessary failed after verification", + error + ); + }); + break; + } + } +} + +FxAccountsDevice.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", +]); + +function urlsafeBase64Encode(buffer) { + return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false }); +} |