Source code

Revision control

Copy as Markdown

Other Tools

/* 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,
import { DEVICE_TYPE_DESKTOP } from "resource://services-sync/constants.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
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 });
}