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 };