tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

sync_auth.sys.mjs (23585B)


      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 file,
      3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      6 import { Log } from "resource://gre/modules/Log.sys.mjs";
      7 
      8 import { Async } from "resource://services-common/async.sys.mjs";
      9 import { TokenServerClient } from "resource://services-common/tokenserverclient.sys.mjs";
     10 import { CryptoUtils } from "moz-src:///services/crypto/modules/utils.sys.mjs";
     11 import { Svc, Utils } from "resource://services-sync/util.sys.mjs";
     12 
     13 import {
     14  LOGIN_FAILED_LOGIN_REJECTED,
     15  LOGIN_FAILED_NETWORK_ERROR,
     16  LOGIN_FAILED_NO_USERNAME,
     17  LOGIN_SUCCEEDED,
     18  MASTER_PASSWORD_LOCKED,
     19  STATUS_OK,
     20 } from "resource://services-sync/constants.sys.mjs";
     21 
     22 const lazy = {};
     23 
     24 // Lazy imports to prevent unnecessary load on startup.
     25 ChromeUtils.defineESModuleGetters(lazy, {
     26  BulkKeyBundle: "resource://services-sync/keys.sys.mjs",
     27  Weave: "resource://services-sync/main.sys.mjs",
     28 });
     29 
     30 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
     31  return ChromeUtils.importESModule(
     32    "resource://gre/modules/FxAccounts.sys.mjs"
     33  ).getFxAccountsSingleton();
     34 });
     35 
     36 ChromeUtils.defineLazyGetter(lazy, "log", function () {
     37  let log = Log.repository.getLogger("Sync.SyncAuthManager");
     38  log.manageLevelFromPref("services.sync.log.logger.identity");
     39  return log;
     40 });
     41 
     42 XPCOMUtils.defineLazyPreferenceGetter(
     43  lazy,
     44  "IGNORE_CACHED_AUTH_CREDENTIALS",
     45  "services.sync.debug.ignoreCachedAuthCredentials"
     46 );
     47 
     48 // FxAccountsCommon.js doesn't use a "namespace", so create one here.
     49 import * as fxAccountsCommon from "resource://gre/modules/FxAccountsCommon.sys.mjs";
     50 
     51 const SCOPE_APP_SYNC = fxAccountsCommon.SCOPE_APP_SYNC;
     52 
     53 const OBSERVER_TOPICS = [
     54  fxAccountsCommon.ONLOGIN_NOTIFICATION,
     55  fxAccountsCommon.ONVERIFIED_NOTIFICATION,
     56  fxAccountsCommon.ONLOGOUT_NOTIFICATION,
     57  fxAccountsCommon.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION,
     58  "weave:connected",
     59 ];
     60 
     61 /*
     62  General authentication error for abstracting authentication
     63  errors from multiple sources (e.g., from FxAccounts, TokenServer).
     64  details is additional details about the error - it might be a string, or
     65  some other error object (which should do the right thing when toString() is
     66  called on it)
     67 */
     68 export function AuthenticationError(details, source) {
     69  this.details = details;
     70  this.source = source;
     71 }
     72 
     73 AuthenticationError.prototype = {
     74  toString() {
     75    return "AuthenticationError(" + this.details + ")";
     76  },
     77 };
     78 
     79 // The `SyncAuthManager` coordinates access authorization to the Sync server.
     80 // Its job is essentially to get us from having a signed-in Firefox Accounts user,
     81 // to knowing the user's sync storage node and having the necessary short-lived
     82 // credentials in order to access it.
     83 //
     84 
     85 export function SyncAuthManager() {
     86  // NOTE: _fxaService and _tokenServerClient are replaced with mocks by
     87  // the test suite.
     88  this._fxaService = lazy.fxAccounts;
     89  this._tokenServerClient = new TokenServerClient();
     90  this._tokenServerClient.observerPrefix = "weave:service";
     91  this._log = lazy.log;
     92  XPCOMUtils.defineLazyPreferenceGetter(
     93    this,
     94    "_username",
     95    "services.sync.username"
     96  );
     97 
     98  this.asyncObserver = Async.asyncObserver(this, lazy.log);
     99  for (let topic of OBSERVER_TOPICS) {
    100    Services.obs.addObserver(this.asyncObserver, topic);
    101  }
    102 }
    103 
    104 SyncAuthManager.prototype = {
    105  _fxaService: null,
    106  _tokenServerClient: null,
    107  // https://docs.services.mozilla.com/token/apis.html
    108  _token: null,
    109  // protection against the user changing underneath us - the uid
    110  // of the current user.
    111  _userUid: null,
    112 
    113  hashedUID() {
    114    const id = this._fxaService.telemetry.getSanitizedUID();
    115    if (!id) {
    116      throw new Error("hashedUID: Don't seem to have previously seen a token");
    117    }
    118    return id;
    119  },
    120 
    121  // Return a hashed version of a deviceID, suitable for telemetry.
    122  hashedDeviceID(deviceID) {
    123    const id = this._fxaService.telemetry.sanitizeDeviceId(deviceID);
    124    if (!id) {
    125      throw new Error("hashedUID: Don't seem to have previously seen a token");
    126    }
    127    return id;
    128  },
    129 
    130  // The "node type" reported to telemetry or null if not specified.
    131  get telemetryNodeType() {
    132    return this._token && this._token.node_type ? this._token.node_type : null;
    133  },
    134 
    135  finalize() {
    136    // After this is called, we can expect Service.identity != this.
    137    for (let topic of OBSERVER_TOPICS) {
    138      Services.obs.removeObserver(this.asyncObserver, topic);
    139    }
    140    this.resetCredentials();
    141    this._userUid = null;
    142  },
    143 
    144  async getSignedInUser() {
    145    let data = await this._fxaService.getSignedInUser();
    146    if (!data) {
    147      this._userUid = null;
    148      return null;
    149    }
    150    if (this._userUid == null) {
    151      this._userUid = data.uid;
    152    } else if (this._userUid != data.uid) {
    153      throw new Error("The signed in user has changed");
    154    }
    155    return data;
    156  },
    157 
    158  logout() {
    159    // This will be called when sync fails (or when the account is being
    160    // unlinked etc).  It may have failed because we got a 401 from a sync
    161    // server, so we nuke the token.  Next time sync runs and wants an
    162    // authentication header, we will notice the lack of the token and fetch a
    163    // new one.
    164    this._token = null;
    165  },
    166 
    167  async observe(subject, topic) {
    168    this._log.debug("observed " + topic);
    169    if (!this.username) {
    170      this._log.info("Sync is not configured, so ignoring the notification");
    171      return;
    172    }
    173    switch (topic) {
    174      case "weave:connected":
    175      case fxAccountsCommon.ONLOGIN_NOTIFICATION: {
    176        this._log.info("Sync has been connected to a logged in user");
    177        this.resetCredentials();
    178        let accountData = await this.getSignedInUser();
    179 
    180        if (!accountData.verified) {
    181          // wait for a verified notification before we kick sync off.
    182          this._log.info("The user is not verified");
    183          break;
    184        }
    185      }
    186      // We've been configured with an already verified user, so fall-through.
    187      // intentional fall-through - the user is verified.
    188      case fxAccountsCommon.ONVERIFIED_NOTIFICATION: {
    189        this._log.info("The user became verified");
    190        lazy.Weave.Status.login = LOGIN_SUCCEEDED;
    191 
    192        // And actually sync. If we've never synced before, we force a full sync.
    193        // If we have, then we are probably just reauthenticating so it's a normal sync.
    194        // We can use any pref that must be set if we've synced before, and check
    195        // the sync lock state because we might already be doing that first sync.
    196        let isFirstSync =
    197          !lazy.Weave.Service.locked &&
    198          !Svc.PrefBranch.getStringPref("client.syncID", null);
    199        if (isFirstSync) {
    200          this._log.info("Doing initial sync actions");
    201          Svc.PrefBranch.setStringPref("firstSync", "resetClient");
    202          Services.obs.notifyObservers(null, "weave:service:setup-complete");
    203        }
    204        // There's no need to wait for sync to complete and it would deadlock
    205        // our AsyncObserver.
    206        if (!Svc.PrefBranch.getBoolPref("testing.tps", false)) {
    207          lazy.Weave.Service.sync({ why: "login" });
    208        }
    209        break;
    210      }
    211 
    212      case fxAccountsCommon.ONLOGOUT_NOTIFICATION:
    213        lazy.Weave.Service.startOver()
    214          .then(() => {
    215            this._log.trace("startOver completed");
    216          })
    217          .catch(err => {
    218            this._log.warn("Failed to reset sync", err);
    219          });
    220        // startOver will cause this instance to be thrown away, so there's
    221        // nothing else to do.
    222        break;
    223 
    224      case fxAccountsCommon.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION:
    225        // throw away token forcing us to fetch a new one later.
    226        this.resetCredentials();
    227        break;
    228    }
    229  },
    230 
    231  /**
    232   * Provide override point for testing token expiration.
    233   */
    234  _now() {
    235    return this._fxaService._internal.now();
    236  },
    237 
    238  get _localtimeOffsetMsec() {
    239    return this._fxaService._internal.localtimeOffsetMsec;
    240  },
    241 
    242  get syncKeyBundle() {
    243    return this._syncKeyBundle;
    244  },
    245 
    246  get username() {
    247    return this._username;
    248  },
    249 
    250  /**
    251   * Set the username value.
    252   *
    253   * Changing the username has the side-effect of wiping credentials.
    254   */
    255  set username(value) {
    256    // setting .username is an old throwback, but it should no longer happen.
    257    throw new Error("don't set the username");
    258  },
    259 
    260  /**
    261   * Resets all calculated credentials we hold for the current user. This will
    262   * *not* force the user to reauthenticate, but instead will force us to
    263   * calculate a new key bundle, fetch a new token, etc.
    264   */
    265  resetCredentials() {
    266    this._syncKeyBundle = null;
    267    this._token = null;
    268    // The cluster URL comes from the token, so resetting it to empty will
    269    // force Sync to not accidentally use a value from an earlier token.
    270    lazy.Weave.Service.clusterURL = null;
    271  },
    272 
    273  /**
    274   * Pre-fetches any information that might help with migration away from this
    275   * identity.  Called after every sync and is really just an optimization that
    276   * allows us to avoid a network request for when we actually need the
    277   * migration info.
    278   */
    279  prefetchMigrationSentinel() {
    280    // nothing to do here until we decide to migrate away from FxA.
    281  },
    282 
    283  /**
    284   * Verify the current auth state, unlocking the master-password if necessary.
    285   *
    286   * Returns a promise that resolves with the current auth state after
    287   * attempting to unlock.
    288   */
    289  async unlockAndVerifyAuthState() {
    290    let data = await this.getSignedInUser();
    291    const fxa = this._fxaService;
    292    if (!data) {
    293      lazy.log.debug("unlockAndVerifyAuthState has no FxA user");
    294      return LOGIN_FAILED_NO_USERNAME;
    295    }
    296    if (!this.username) {
    297      lazy.log.debug(
    298        "unlockAndVerifyAuthState finds that sync isn't configured"
    299      );
    300      return LOGIN_FAILED_NO_USERNAME;
    301    }
    302    if (!data.verified) {
    303      // Treat not verified as if the user needs to re-auth, so the browser
    304      // UI reflects the state.
    305      lazy.log.debug("unlockAndVerifyAuthState has an unverified user");
    306      return LOGIN_FAILED_LOGIN_REJECTED;
    307    }
    308    if (await fxa.keys.canGetKeyForScope(SCOPE_APP_SYNC)) {
    309      lazy.log.debug(
    310        "unlockAndVerifyAuthState already has (or can fetch) sync keys"
    311      );
    312      return STATUS_OK;
    313    }
    314    // so no keys - ensure MP unlocked.
    315    if (!Utils.ensureMPUnlocked()) {
    316      // user declined to unlock, so we don't know if they are stored there.
    317      lazy.log.debug(
    318        "unlockAndVerifyAuthState: user declined to unlock master-password"
    319      );
    320      return MASTER_PASSWORD_LOCKED;
    321    }
    322    // If we still can't get keys it probably means the user authenticated
    323    // without unlocking the MP or cleared the saved logins, so we've now
    324    // lost them - the user will need to reauth before continuing.
    325    let result;
    326    if (await fxa.keys.canGetKeyForScope(SCOPE_APP_SYNC)) {
    327      result = STATUS_OK;
    328    } else {
    329      result = LOGIN_FAILED_LOGIN_REJECTED;
    330    }
    331    lazy.log.debug(
    332      "unlockAndVerifyAuthState re-fetched credentials and is returning",
    333      result
    334    );
    335    return result;
    336  },
    337 
    338  /**
    339   * Do we have a non-null, not yet expired token for the user currently
    340   * signed in?
    341   */
    342  _hasValidToken() {
    343    // If pref is set to ignore cached authentication credentials for debugging,
    344    // then return false to force the fetching of a new token.
    345    if (lazy.IGNORE_CACHED_AUTH_CREDENTIALS) {
    346      return false;
    347    }
    348    if (!this._token) {
    349      return false;
    350    }
    351    if (this._token.expiration < this._now()) {
    352      return false;
    353    }
    354    return true;
    355  },
    356 
    357  // Get our tokenServerURL - a private helper. Returns a string.
    358  get _tokenServerUrl() {
    359    // We used to support services.sync.tokenServerURI but this was a
    360    // pain-point for people using non-default servers as Sync may auto-reset
    361    // all services.sync prefs. So if that still exists, it wins.
    362    let url = Svc.PrefBranch.getStringPref("tokenServerURI", null); // Svc.PrefBranch "root" is services.sync
    363    if (!url) {
    364      url = Services.prefs.getStringPref("identity.sync.tokenserver.uri");
    365    }
    366    while (url.endsWith("/")) {
    367      // trailing slashes cause problems...
    368      url = url.slice(0, -1);
    369    }
    370    return url;
    371  },
    372 
    373  // Refresh the sync token for our user. Returns a promise that resolves
    374  // with a token, or rejects with an error.
    375  async _fetchTokenForUser() {
    376    const fxa = this._fxaService;
    377    // We need keys for things to work.  If we don't have them, just
    378    // return null for the token - sync calling unlockAndVerifyAuthState()
    379    // before actually syncing will setup the error states if necessary.
    380    if (!(await fxa.keys.canGetKeyForScope(SCOPE_APP_SYNC))) {
    381      this._log.info(
    382        "Unable to fetch keys (master-password locked?), so aborting token fetch"
    383      );
    384      throw new Error("Can't fetch a token as we can't get keys");
    385    }
    386 
    387    // Do the token dance, with a retry in case of transient auth failure.
    388    // We need to prove that we know the sync key in order to get a token
    389    // from the tokenserver.
    390    let getToken = async (key, accessToken) => {
    391      this._log.info("Getting a sync token from", this._tokenServerUrl);
    392      let token = await this._fetchTokenUsingOAuth(key, accessToken);
    393      this._log.trace("Successfully got a token");
    394      return token;
    395    };
    396 
    397    const ttl = fxAccountsCommon.OAUTH_TOKEN_FOR_SYNC_LIFETIME_SECONDS;
    398    try {
    399      let token, key;
    400      try {
    401        this._log.info("Getting sync key");
    402        const tokenAndKey = await fxa.getOAuthTokenAndKey({
    403          scope: SCOPE_APP_SYNC,
    404          ttl,
    405        });
    406 
    407        key = tokenAndKey.key;
    408        if (!key) {
    409          throw new Error("browser does not have the sync key, cannot sync");
    410        }
    411        token = await getToken(key, tokenAndKey.token);
    412      } catch (err) {
    413        // If we get a 401 fetching the token it may be that our auth tokens needed
    414        // to be regenerated; retry exactly once.
    415        if (!err.response || err.response.status !== 401) {
    416          throw err;
    417        }
    418        this._log.warn(
    419          "Token server returned 401, retrying token fetch with fresh credentials"
    420        );
    421        const tokenAndKey = await fxa.getOAuthTokenAndKey({
    422          scope: SCOPE_APP_SYNC,
    423          ttl,
    424        });
    425        token = await getToken(tokenAndKey.key, tokenAndKey.token);
    426      }
    427      // TODO: Make it be only 80% of the duration, so refresh the token
    428      // before it actually expires. This is to avoid sync storage errors
    429      // otherwise, we may briefly enter a "needs reauthentication" state.
    430      // (XXX - the above may no longer be true - someone should check ;)
    431      token.expiration = this._now() + token.duration * 1000 * 0.8;
    432      if (!this._syncKeyBundle) {
    433        this._syncKeyBundle = lazy.BulkKeyBundle.fromJWK(key);
    434      }
    435      lazy.Weave.Status.login = LOGIN_SUCCEEDED;
    436      this._token = token;
    437      return token;
    438    } catch (caughtErr) {
    439      let err = caughtErr; // The error we will rethrow.
    440 
    441      // TODO: unify these errors - we need to handle errors thrown by
    442      // both tokenserverclient and hawkclient.
    443      // A tokenserver error thrown based on a bad response.
    444      if (err.response && err.response.status === 401) {
    445        err = new AuthenticationError(err, "tokenserver");
    446        // A hawkclient error.
    447      } else if (err.code && err.code === 401) {
    448        err = new AuthenticationError(err, "hawkclient");
    449        // An FxAccounts.sys.mjs error.
    450      } else if (err.message == fxAccountsCommon.ERROR_AUTH_ERROR) {
    451        err = new AuthenticationError(err, "fxaccounts");
    452      }
    453 
    454      // TODO: write tests to make sure that different auth error cases are handled here
    455      // properly: auth error getting oauth token, auth error getting sync token (invalid
    456      // generation or client-state error)
    457      if (err instanceof AuthenticationError) {
    458        this._log.error("Authentication error in _fetchTokenForUser", err);
    459        // set it to the "fatal" LOGIN_FAILED_LOGIN_REJECTED reason.
    460        lazy.Weave.Status.login = LOGIN_FAILED_LOGIN_REJECTED;
    461      } else {
    462        this._log.error("Non-authentication error in _fetchTokenForUser", err);
    463        // for now assume it is just a transient network related problem
    464        // (although sadly, it might also be a regular unhandled exception)
    465        lazy.Weave.Status.login = LOGIN_FAILED_NETWORK_ERROR;
    466      }
    467      throw err;
    468    }
    469  },
    470 
    471  /**
    472   * Exchanges an OAuth access_token for a TokenServer token.
    473   *
    474   * @returns {Promise}
    475   * @private
    476   */
    477  async _fetchTokenUsingOAuth(key, accessToken) {
    478    this._log.debug("Getting a token using OAuth");
    479    const fxa = this._fxaService;
    480    const headers = {
    481      "X-KeyId": key.kid,
    482    };
    483 
    484    return this._tokenServerClient
    485      .getTokenUsingOAuth(this._tokenServerUrl, accessToken, headers)
    486      .catch(async err => {
    487        if (err.response && err.response.status === 401) {
    488          // remove the cached token if we cannot authorize with it.
    489          // we have to do this here because we know which `token` to remove
    490          // from cache.
    491          await fxa.removeCachedOAuthToken({ token: accessToken });
    492        }
    493 
    494        // continue the error chain, so other handlers can deal with the error.
    495        throw err;
    496      });
    497  },
    498 
    499  // Returns a promise that is resolved with a valid token for the current
    500  // user, or rejects if one can't be obtained.
    501  // NOTE: This does all the authentication for Sync - it both sets the
    502  // key bundle (ie, decryption keys) and does the token fetch. These 2
    503  // concepts could be decoupled, but there doesn't seem any value in that
    504  // currently.
    505  async _ensureValidToken(forceNewToken = false) {
    506    let signedInUser = await this.getSignedInUser();
    507    if (!signedInUser) {
    508      throw new Error("no user is logged in");
    509    }
    510    if (!signedInUser.verified) {
    511      throw new Error("user is not verified");
    512    }
    513 
    514    await this.asyncObserver.promiseObserversComplete();
    515 
    516    if (!forceNewToken && this._hasValidToken()) {
    517      this._log.trace("_ensureValidToken already has one");
    518      return this._token;
    519    }
    520 
    521    // We are going to grab a new token - re-use the same promise if we are
    522    // already fetching one.
    523    if (!this._ensureValidTokenPromise) {
    524      this._ensureValidTokenPromise = this.__ensureValidToken().finally(() => {
    525        this._ensureValidTokenPromise = null;
    526      });
    527    }
    528    return this._ensureValidTokenPromise;
    529  },
    530 
    531  async __ensureValidToken() {
    532    // reset this._token as a safety net to reduce the possibility of us
    533    // repeatedly attempting to use an invalid token if _fetchTokenForUser throws.
    534    this._token = null;
    535    try {
    536      let token = await this._fetchTokenForUser();
    537      this._token = token;
    538      // This is a little bit of a hack. The tokenserver tells us a HMACed version
    539      // of the FxA uid which we can use for metrics purposes without revealing the
    540      // user's true uid. It conceptually belongs to FxA but we get it from tokenserver
    541      // for legacy reasons. Hand it back to the FxA client code to deal with.
    542      this._fxaService.telemetry._setHashedUID(token.hashed_fxa_uid);
    543      return token;
    544    } finally {
    545      Services.obs.notifyObservers(null, "weave:service:login:got-hashed-id");
    546    }
    547  },
    548 
    549  getResourceAuthenticator() {
    550    return this._getAuthenticationHeader.bind(this);
    551  },
    552 
    553  /**
    554   * @return a Hawk HTTP Authorization Header, lightly wrapped, for the .uri
    555   * of a RESTRequest or AsyncResponse object.
    556   */
    557  async _getAuthenticationHeader(httpObject, method) {
    558    // Note that in failure states we return null, causing the request to be
    559    // made without authorization headers, thereby presumably causing a 401,
    560    // which causes Sync to log out. If we throw, this may not happen as
    561    // expected.
    562    try {
    563      await this._ensureValidToken();
    564    } catch (ex) {
    565      this._log.error("Failed to fetch a token for authentication", ex);
    566      return null;
    567    }
    568    if (!this._token) {
    569      return null;
    570    }
    571    let credentials = { id: this._token.id, key: this._token.key };
    572    method = method || httpObject.method;
    573 
    574    // Get the local clock offset from the Firefox Accounts server.  This should
    575    // be close to the offset from the storage server.
    576    let options = {
    577      now: this._now(),
    578      localtimeOffsetMsec: this._localtimeOffsetMsec,
    579      credentials,
    580    };
    581 
    582    let headerValue = await CryptoUtils.computeHAWK(
    583      httpObject.uri,
    584      method,
    585      options
    586    );
    587    return { headers: { authorization: headerValue.field } };
    588  },
    589 
    590  /**
    591   * Determine the cluster for the current user and update state.
    592   * Returns true if a new cluster URL was found and it is different from
    593   * the existing cluster URL, false otherwise.
    594   */
    595  async setCluster() {
    596    // Make sure we didn't get some unexpected response for the cluster.
    597    let cluster = await this._findCluster();
    598    this._log.debug("Cluster value = " + cluster);
    599    if (cluster == null) {
    600      return false;
    601    }
    602 
    603    // Convert from the funky "String object with additional properties" that
    604    // resource.js returns to a plain-old string.
    605    cluster = cluster.toString();
    606    // Don't update stuff if we already have the right cluster
    607    if (cluster == lazy.Weave.Service.clusterURL) {
    608      return false;
    609    }
    610 
    611    this._log.debug("Setting cluster to " + cluster);
    612    lazy.Weave.Service.clusterURL = cluster;
    613 
    614    return true;
    615  },
    616 
    617  async _findCluster() {
    618    try {
    619      // Ensure we are ready to authenticate and have a valid token.
    620      // We need to handle node reassignment here.  If we are being asked
    621      // for a clusterURL while the service already has a clusterURL, then
    622      // it's likely a 401 was received using the existing token - in which
    623      // case we just discard the existing token and fetch a new one.
    624      let forceNewToken = false;
    625      if (lazy.Weave.Service.clusterURL) {
    626        this._log.debug(
    627          "_findCluster has a pre-existing clusterURL, so fetching a new token token"
    628        );
    629        forceNewToken = true;
    630      }
    631      let token = await this._ensureValidToken(forceNewToken);
    632      let endpoint = token.endpoint;
    633      // For Sync 1.5 storage endpoints, we use the base endpoint verbatim.
    634      // However, it should end in "/" because we will extend it with
    635      // well known path components. So we add a "/" if it's missing.
    636      if (!endpoint.endsWith("/")) {
    637        endpoint += "/";
    638      }
    639      this._log.debug("_findCluster returning " + endpoint);
    640      return endpoint;
    641    } catch (err) {
    642      this._log.info("Failed to fetch the cluster URL", err);
    643      // service.js's verifyLogin() method will attempt to fetch a cluster
    644      // URL when it sees a 401.  If it gets null, it treats it as a "real"
    645      // auth error and sets Status.login to LOGIN_FAILED_LOGIN_REJECTED, which
    646      // in turn causes a notification bar to appear informing the user they
    647      // need to re-authenticate.
    648      // On the other hand, if fetching the cluster URL fails with an exception,
    649      // verifyLogin() assumes it is a transient error, and thus doesn't show
    650      // the notification bar under the assumption the issue will resolve
    651      // itself.
    652      // Thus:
    653      // * On a real 401, we must return null.
    654      // * On any other problem we must let an exception bubble up.
    655      if (err instanceof AuthenticationError) {
    656        return null;
    657      }
    658      throw err;
    659    }
    660  },
    661 };