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 } from "resource://gre/modules/Log.sys.mjs";
import { Async } from "resource://services-common/async.sys.mjs";
import { TokenServerClient } from "resource://services-common/tokenserverclient.sys.mjs";
import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs";
import { Svc, Utils } from "resource://services-sync/util.sys.mjs";
import {
LOGIN_FAILED_LOGIN_REJECTED,
LOGIN_FAILED_NETWORK_ERROR,
LOGIN_FAILED_NO_USERNAME,
LOGIN_SUCCEEDED,
MASTER_PASSWORD_LOCKED,
STATUS_OK,
const lazy = {};
// Lazy imports to prevent unnecessary load on startup.
ChromeUtils.defineESModuleGetters(lazy, {
});
ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
return ChromeUtils.importESModule(
).getFxAccountsSingleton();
});
ChromeUtils.defineLazyGetter(lazy, "log", function () {
let log = Log.repository.getLogger("Sync.SyncAuthManager");
log.manageLevelFromPref("services.sync.log.logger.identity");
return log;
});
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"IGNORE_CACHED_AUTH_CREDENTIALS",
"services.sync.debug.ignoreCachedAuthCredentials"
);
// FxAccountsCommon.js doesn't use a "namespace", so create one here.
import * as fxAccountsCommon from "resource://gre/modules/FxAccountsCommon.sys.mjs";
const SCOPE_OLD_SYNC = fxAccountsCommon.SCOPE_OLD_SYNC;
const OBSERVER_TOPICS = [
fxAccountsCommon.ONLOGIN_NOTIFICATION,
fxAccountsCommon.ONVERIFIED_NOTIFICATION,
fxAccountsCommon.ONLOGOUT_NOTIFICATION,
fxAccountsCommon.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION,
"weave:connected",
];
/*
General authentication error for abstracting authentication
errors from multiple sources (e.g., from FxAccounts, TokenServer).
details is additional details about the error - it might be a string, or
some other error object (which should do the right thing when toString() is
called on it)
*/
export function AuthenticationError(details, source) {
this.details = details;
this.source = source;
}
AuthenticationError.prototype = {
toString() {
return "AuthenticationError(" + this.details + ")";
},
};
// The `SyncAuthManager` coordinates access authorization to the Sync server.
// Its job is essentially to get us from having a signed-in Firefox Accounts user,
// to knowing the user's sync storage node and having the necessary short-lived
// credentials in order to access it.
//
export function SyncAuthManager() {
// NOTE: _fxaService and _tokenServerClient are replaced with mocks by
// the test suite.
this._fxaService = lazy.fxAccounts;
this._tokenServerClient = new TokenServerClient();
this._tokenServerClient.observerPrefix = "weave:service";
this._log = lazy.log;
XPCOMUtils.defineLazyPreferenceGetter(
this,
"_username",
"services.sync.username"
);
this.asyncObserver = Async.asyncObserver(this, lazy.log);
for (let topic of OBSERVER_TOPICS) {
Services.obs.addObserver(this.asyncObserver, topic);
}
}
SyncAuthManager.prototype = {
_fxaService: null,
_tokenServerClient: null,
_token: null,
// protection against the user changing underneath us - the uid
// of the current user.
_userUid: null,
hashedUID() {
const id = this._fxaService.telemetry.getSanitizedUID();
if (!id) {
throw new Error("hashedUID: Don't seem to have previously seen a token");
}
return id;
},
// Return a hashed version of a deviceID, suitable for telemetry.
hashedDeviceID(deviceID) {
const id = this._fxaService.telemetry.sanitizeDeviceId(deviceID);
if (!id) {
throw new Error("hashedUID: Don't seem to have previously seen a token");
}
return id;
},
// The "node type" reported to telemetry or null if not specified.
get telemetryNodeType() {
return this._token && this._token.node_type ? this._token.node_type : null;
},
finalize() {
// After this is called, we can expect Service.identity != this.
for (let topic of OBSERVER_TOPICS) {
Services.obs.removeObserver(this.asyncObserver, topic);
}
this.resetCredentials();
this._userUid = null;
},
async getSignedInUser() {
let data = await this._fxaService.getSignedInUser();
if (!data) {
this._userUid = null;
return null;
}
if (this._userUid == null) {
this._userUid = data.uid;
} else if (this._userUid != data.uid) {
throw new Error("The signed in user has changed");
}
return data;
},
logout() {
// This will be called when sync fails (or when the account is being
// unlinked etc). It may have failed because we got a 401 from a sync
// server, so we nuke the token. Next time sync runs and wants an
// authentication header, we will notice the lack of the token and fetch a
// new one.
this._token = null;
},
async observe(subject, topic) {
this._log.debug("observed " + topic);
if (!this.username) {
this._log.info("Sync is not configured, so ignoring the notification");
return;
}
switch (topic) {
case "weave:connected":
case fxAccountsCommon.ONLOGIN_NOTIFICATION: {
this._log.info("Sync has been connected to a logged in user");
this.resetCredentials();
let accountData = await this.getSignedInUser();
if (!accountData.verified) {
// wait for a verified notification before we kick sync off.
this._log.info("The user is not verified");
break;
}
}
// We've been configured with an already verified user, so fall-through.
// intentional fall-through - the user is verified.
case fxAccountsCommon.ONVERIFIED_NOTIFICATION: {
this._log.info("The user became verified");
lazy.Weave.Status.login = LOGIN_SUCCEEDED;
// And actually sync. If we've never synced before, we force a full sync.
// If we have, then we are probably just reauthenticating so it's a normal sync.
// We can use any pref that must be set if we've synced before, and check
// the sync lock state because we might already be doing that first sync.
let isFirstSync =
!lazy.Weave.Service.locked &&
!Svc.PrefBranch.getStringPref("client.syncID", null);
if (isFirstSync) {
this._log.info("Doing initial sync actions");
Svc.PrefBranch.setStringPref("firstSync", "resetClient");
Services.obs.notifyObservers(null, "weave:service:setup-complete");
}
// There's no need to wait for sync to complete and it would deadlock
// our AsyncObserver.
if (!Svc.PrefBranch.getBoolPref("testing.tps", false)) {
lazy.Weave.Service.sync({ why: "login" });
}
break;
}
case fxAccountsCommon.ONLOGOUT_NOTIFICATION:
lazy.Weave.Service.startOver()
.then(() => {
this._log.trace("startOver completed");
})
.catch(err => {
this._log.warn("Failed to reset sync", err);
});
// startOver will cause this instance to be thrown away, so there's
// nothing else to do.
break;
case fxAccountsCommon.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION:
// throw away token forcing us to fetch a new one later.
this.resetCredentials();
break;
}
},
/**
* Provide override point for testing token expiration.
*/
_now() {
return this._fxaService._internal.now();
},
get _localtimeOffsetMsec() {
return this._fxaService._internal.localtimeOffsetMsec;
},
get syncKeyBundle() {
return this._syncKeyBundle;
},
get username() {
return this._username;
},
/**
* Set the username value.
*
* Changing the username has the side-effect of wiping credentials.
*/
set username(value) {
// setting .username is an old throwback, but it should no longer happen.
throw new Error("don't set the username");
},
/**
* Resets all calculated credentials we hold for the current user. This will
* *not* force the user to reauthenticate, but instead will force us to
* calculate a new key bundle, fetch a new token, etc.
*/
resetCredentials() {
this._syncKeyBundle = null;
this._token = null;
// The cluster URL comes from the token, so resetting it to empty will
// force Sync to not accidentally use a value from an earlier token.
lazy.Weave.Service.clusterURL = null;
},
/**
* Pre-fetches any information that might help with migration away from this
* identity. Called after every sync and is really just an optimization that
* allows us to avoid a network request for when we actually need the
* migration info.
*/
prefetchMigrationSentinel() {
// nothing to do here until we decide to migrate away from FxA.
},
/**
* Verify the current auth state, unlocking the master-password if necessary.
*
* Returns a promise that resolves with the current auth state after
* attempting to unlock.
*/
async unlockAndVerifyAuthState() {
let data = await this.getSignedInUser();
const fxa = this._fxaService;
if (!data) {
lazy.log.debug("unlockAndVerifyAuthState has no FxA user");
return LOGIN_FAILED_NO_USERNAME;
}
if (!this.username) {
lazy.log.debug(
"unlockAndVerifyAuthState finds that sync isn't configured"
);
return LOGIN_FAILED_NO_USERNAME;
}
if (!data.verified) {
// Treat not verified as if the user needs to re-auth, so the browser
// UI reflects the state.
lazy.log.debug("unlockAndVerifyAuthState has an unverified user");
return LOGIN_FAILED_LOGIN_REJECTED;
}
if (await fxa.keys.canGetKeyForScope(SCOPE_OLD_SYNC)) {
lazy.log.debug(
"unlockAndVerifyAuthState already has (or can fetch) sync keys"
);
return STATUS_OK;
}
// so no keys - ensure MP unlocked.
if (!Utils.ensureMPUnlocked()) {
// user declined to unlock, so we don't know if they are stored there.
lazy.log.debug(
"unlockAndVerifyAuthState: user declined to unlock master-password"
);
return MASTER_PASSWORD_LOCKED;
}
// If we still can't get keys it probably means the user authenticated
// without unlocking the MP or cleared the saved logins, so we've now
// lost them - the user will need to reauth before continuing.
let result;
if (await fxa.keys.canGetKeyForScope(SCOPE_OLD_SYNC)) {
result = STATUS_OK;
} else {
result = LOGIN_FAILED_LOGIN_REJECTED;
}
lazy.log.debug(
"unlockAndVerifyAuthState re-fetched credentials and is returning",
result
);
return result;
},
/**
* Do we have a non-null, not yet expired token for the user currently
* signed in?
*/
_hasValidToken() {
// If pref is set to ignore cached authentication credentials for debugging,
// then return false to force the fetching of a new token.
if (lazy.IGNORE_CACHED_AUTH_CREDENTIALS) {
return false;
}
if (!this._token) {
return false;
}
if (this._token.expiration < this._now()) {
return false;
}
return true;
},
// Get our tokenServerURL - a private helper. Returns a string.
get _tokenServerUrl() {
// We used to support services.sync.tokenServerURI but this was a
// pain-point for people using non-default servers as Sync may auto-reset
// all services.sync prefs. So if that still exists, it wins.
let url = Svc.PrefBranch.getStringPref("tokenServerURI", null); // Svc.PrefBranch "root" is services.sync
if (!url) {
url = Services.prefs.getStringPref("identity.sync.tokenserver.uri");
}
while (url.endsWith("/")) {
// trailing slashes cause problems...
url = url.slice(0, -1);
}
return url;
},
// Refresh the sync token for our user. Returns a promise that resolves
// with a token, or rejects with an error.
async _fetchTokenForUser() {
const fxa = this._fxaService;
// We need keys for things to work. If we don't have them, just
// return null for the token - sync calling unlockAndVerifyAuthState()
// before actually syncing will setup the error states if necessary.
if (!(await fxa.keys.canGetKeyForScope(SCOPE_OLD_SYNC))) {
this._log.info(
"Unable to fetch keys (master-password locked?), so aborting token fetch"
);
throw new Error("Can't fetch a token as we can't get keys");
}
// Do the token dance, with a retry in case of transient auth failure.
// We need to prove that we know the sync key in order to get a token
// from the tokenserver.
let getToken = async (key, accessToken) => {
this._log.info("Getting a sync token from", this._tokenServerUrl);
let token = await this._fetchTokenUsingOAuth(key, accessToken);
this._log.trace("Successfully got a token");
return token;
};
const ttl = fxAccountsCommon.OAUTH_TOKEN_FOR_SYNC_LIFETIME_SECONDS;
try {
let token, key;
try {
this._log.info("Getting sync key");
const tokenAndKey = await fxa.getOAuthTokenAndKey({
scope: SCOPE_OLD_SYNC,
ttl,
});
key = tokenAndKey.key;
if (!key) {
throw new Error("browser does not have the sync key, cannot sync");
}
token = await getToken(key, tokenAndKey.token);
} catch (err) {
// If we get a 401 fetching the token it may be that our auth tokens needed
// to be regenerated; retry exactly once.
if (!err.response || err.response.status !== 401) {
throw err;
}
this._log.warn(
"Token server returned 401, retrying token fetch with fresh credentials"
);
const tokenAndKey = await fxa.getOAuthTokenAndKey({
scope: SCOPE_OLD_SYNC,
ttl,
});
token = await getToken(tokenAndKey.key, tokenAndKey.token);
}
// TODO: Make it be only 80% of the duration, so refresh the token
// before it actually expires. This is to avoid sync storage errors
// otherwise, we may briefly enter a "needs reauthentication" state.
// (XXX - the above may no longer be true - someone should check ;)
token.expiration = this._now() + token.duration * 1000 * 0.8;
if (!this._syncKeyBundle) {
this._syncKeyBundle = lazy.BulkKeyBundle.fromJWK(key);
}
lazy.Weave.Status.login = LOGIN_SUCCEEDED;
this._token = token;
return token;
} catch (caughtErr) {
let err = caughtErr; // The error we will rethrow.
// TODO: unify these errors - we need to handle errors thrown by
// both tokenserverclient and hawkclient.
// A tokenserver error thrown based on a bad response.
if (err.response && err.response.status === 401) {
err = new AuthenticationError(err, "tokenserver");
// A hawkclient error.
} else if (err.code && err.code === 401) {
err = new AuthenticationError(err, "hawkclient");
// An FxAccounts.sys.mjs error.
} else if (err.message == fxAccountsCommon.ERROR_AUTH_ERROR) {
err = new AuthenticationError(err, "fxaccounts");
}
// TODO: write tests to make sure that different auth error cases are handled here
// properly: auth error getting oauth token, auth error getting sync token (invalid
// generation or client-state error)
if (err instanceof AuthenticationError) {
this._log.error("Authentication error in _fetchTokenForUser", err);
// set it to the "fatal" LOGIN_FAILED_LOGIN_REJECTED reason.
lazy.Weave.Status.login = LOGIN_FAILED_LOGIN_REJECTED;
} else {
this._log.error("Non-authentication error in _fetchTokenForUser", err);
// for now assume it is just a transient network related problem
// (although sadly, it might also be a regular unhandled exception)
lazy.Weave.Status.login = LOGIN_FAILED_NETWORK_ERROR;
}
throw err;
}
},
/**
* Exchanges an OAuth access_token for a TokenServer token.
* @returns {Promise}
* @private
*/
async _fetchTokenUsingOAuth(key, accessToken) {
this._log.debug("Getting a token using OAuth");
const fxa = this._fxaService;
const headers = {
"X-KeyId": key.kid,
};
return this._tokenServerClient
.getTokenUsingOAuth(this._tokenServerUrl, accessToken, headers)
.catch(async err => {
if (err.response && err.response.status === 401) {
// remove the cached token if we cannot authorize with it.
// we have to do this here because we know which `token` to remove
// from cache.
await fxa.removeCachedOAuthToken({ token: accessToken });
}
// continue the error chain, so other handlers can deal with the error.
throw err;
});
},
// Returns a promise that is resolved with a valid token for the current
// user, or rejects if one can't be obtained.
// NOTE: This does all the authentication for Sync - it both sets the
// key bundle (ie, decryption keys) and does the token fetch. These 2
// concepts could be decoupled, but there doesn't seem any value in that
// currently.
async _ensureValidToken(forceNewToken = false) {
let signedInUser = await this.getSignedInUser();
if (!signedInUser) {
throw new Error("no user is logged in");
}
if (!signedInUser.verified) {
throw new Error("user is not verified");
}
await this.asyncObserver.promiseObserversComplete();
if (!forceNewToken && this._hasValidToken()) {
this._log.trace("_ensureValidToken already has one");
return this._token;
}
// We are going to grab a new token - re-use the same promise if we are
// already fetching one.
if (!this._ensureValidTokenPromise) {
this._ensureValidTokenPromise = this.__ensureValidToken().finally(() => {
this._ensureValidTokenPromise = null;
});
}
return this._ensureValidTokenPromise;
},
async __ensureValidToken() {
// reset this._token as a safety net to reduce the possibility of us
// repeatedly attempting to use an invalid token if _fetchTokenForUser throws.
this._token = null;
try {
let token = await this._fetchTokenForUser();
this._token = token;
// This is a little bit of a hack. The tokenserver tells us a HMACed version
// of the FxA uid which we can use for metrics purposes without revealing the
// user's true uid. It conceptually belongs to FxA but we get it from tokenserver
// for legacy reasons. Hand it back to the FxA client code to deal with.
this._fxaService.telemetry._setHashedUID(token.hashed_fxa_uid);
return token;
} finally {
Services.obs.notifyObservers(null, "weave:service:login:got-hashed-id");
}
},
getResourceAuthenticator() {
return this._getAuthenticationHeader.bind(this);
},
/**
* @return a Hawk HTTP Authorization Header, lightly wrapped, for the .uri
* of a RESTRequest or AsyncResponse object.
*/
async _getAuthenticationHeader(httpObject, method) {
// Note that in failure states we return null, causing the request to be
// made without authorization headers, thereby presumably causing a 401,
// which causes Sync to log out. If we throw, this may not happen as
// expected.
try {
await this._ensureValidToken();
} catch (ex) {
this._log.error("Failed to fetch a token for authentication", ex);
return null;
}
if (!this._token) {
return null;
}
let credentials = { id: this._token.id, key: this._token.key };
method = method || httpObject.method;
// Get the local clock offset from the Firefox Accounts server. This should
// be close to the offset from the storage server.
let options = {
now: this._now(),
localtimeOffsetMsec: this._localtimeOffsetMsec,
credentials,
};
let headerValue = await CryptoUtils.computeHAWK(
httpObject.uri,
method,
options
);
return { headers: { authorization: headerValue.field } };
},
/**
* Determine the cluster for the current user and update state.
* Returns true if a new cluster URL was found and it is different from
* the existing cluster URL, false otherwise.
*/
async setCluster() {
// Make sure we didn't get some unexpected response for the cluster.
let cluster = await this._findCluster();
this._log.debug("Cluster value = " + cluster);
if (cluster == null) {
return false;
}
// Convert from the funky "String object with additional properties" that
// resource.js returns to a plain-old string.
cluster = cluster.toString();
// Don't update stuff if we already have the right cluster
if (cluster == lazy.Weave.Service.clusterURL) {
return false;
}
this._log.debug("Setting cluster to " + cluster);
lazy.Weave.Service.clusterURL = cluster;
return true;
},
async _findCluster() {
try {
// Ensure we are ready to authenticate and have a valid token.
// We need to handle node reassignment here. If we are being asked
// for a clusterURL while the service already has a clusterURL, then
// it's likely a 401 was received using the existing token - in which
// case we just discard the existing token and fetch a new one.
let forceNewToken = false;
if (lazy.Weave.Service.clusterURL) {
this._log.debug(
"_findCluster has a pre-existing clusterURL, so fetching a new token token"
);
forceNewToken = true;
}
let token = await this._ensureValidToken(forceNewToken);
let endpoint = token.endpoint;
// For Sync 1.5 storage endpoints, we use the base endpoint verbatim.
// However, it should end in "/" because we will extend it with
// well known path components. So we add a "/" if it's missing.
if (!endpoint.endsWith("/")) {
endpoint += "/";
}
this._log.debug("_findCluster returning " + endpoint);
return endpoint;
} catch (err) {
this._log.info("Failed to fetch the cluster URL", err);
// service.js's verifyLogin() method will attempt to fetch a cluster
// URL when it sees a 401. If it gets null, it treats it as a "real"
// auth error and sets Status.login to LOGIN_FAILED_LOGIN_REJECTED, which
// in turn causes a notification bar to appear informing the user they
// need to re-authenticate.
// On the other hand, if fetching the cluster URL fails with an exception,
// verifyLogin() assumes it is a transient error, and thus doesn't show
// the notification bar under the assumption the issue will resolve
// itself.
// Thus:
// * On a real 401, we must return null.
// * On any other problem we must let an exception bubble up.
if (err instanceof AuthenticationError) {
return null;
}
throw err;
}
},
};