summaryrefslogtreecommitdiffstats
path: root/services/sync/modules/sync_auth.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /services/sync/modules/sync_auth.sys.mjs
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'services/sync/modules/sync_auth.sys.mjs')
-rw-r--r--services/sync/modules/sync_auth.sys.mjs655
1 files changed, 655 insertions, 0 deletions
diff --git a/services/sync/modules/sync_auth.sys.mjs b/services/sync/modules/sync_auth.sys.mjs
new file mode 100644
index 0000000000..6b8da4061c
--- /dev/null
+++ b/services/sync/modules/sync_auth.sys.mjs
@@ -0,0 +1,655 @@
+/* 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,
+} from "resource://services-sync/constants.sys.mjs";
+
+const lazy = {};
+
+// Lazy imports to prevent unnecessary load on startup.
+ChromeUtils.defineESModuleGetters(lazy, {
+ BulkKeyBundle: "resource://services-sync/keys.sys.mjs",
+ Weave: "resource://services-sync/main.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ ).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,
+ // https://docs.services.mozilla.com/token/apis.html
+ _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, data) {
+ 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(service) {
+ // 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 => {
+ this._log.info("Getting a sync token from", this._tokenServerUrl);
+ let token = await this._fetchTokenUsingOAuth(key);
+ this._log.trace("Successfully got a token");
+ return token;
+ };
+
+ try {
+ let token, key;
+ try {
+ this._log.info("Getting sync key");
+ key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ if (!key) {
+ throw new Error("browser does not have the sync key, cannot sync");
+ }
+ token = await getToken(key);
+ } 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"
+ );
+ key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ token = await getToken(key);
+ }
+ // 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.jsm 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;
+ }
+ },
+
+ /**
+ * Generates an OAuth access_token using the OLD_SYNC scope and exchanges it
+ * for a TokenServer token.
+ *
+ * @returns {Promise}
+ * @private
+ */
+ async _fetchTokenUsingOAuth(key) {
+ this._log.debug("Getting a token using OAuth");
+ const fxa = this._fxaService;
+ const ttl = fxAccountsCommon.OAUTH_TOKEN_FOR_SYNC_LIFETIME_SECONDS;
+ const accessToken = await fxa.getOAuthToken({ scope: SCOPE_OLD_SYNC, ttl });
+ 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;
+ }
+ },
+};