summaryrefslogtreecommitdiffstats
path: root/services/sync/modules/sync_auth.sys.mjs
blob: 2f2bd2af7779c41f4cea0311c4fd25012a81c944 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
/* 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",
});

XPCOMUtils.defineLazyGetter(lazy, "fxAccounts", () => {
  return ChromeUtils.importESModule(
    "resource://gre/modules/FxAccounts.sys.mjs"
  ).getFxAccountsSingleton();
});

XPCOMUtils.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.
var fxAccountsCommon = ChromeUtils.import(
  "resource://gre/modules/FxAccountsCommon.js"
);

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.Prefs.get("client.syncID", null);
        if (isFirstSync) {
          this._log.info("Doing initial sync actions");
          Svc.Prefs.set("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.Prefs.get("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.Prefs.get("tokenServerURI"); // Svc.Prefs "root" is services.sync
    if (!url) {
      url = Services.prefs.getCharPref("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;
    }
  },
};