Backed out changeset 3b4a3a45aa1a (bug 1906140) @ browser_sidebar_prefs.js CLOSED...
[gecko.git] / services / fxaccounts / FxAccounts.sys.mjs
blobc9554dcc2dbbbc7e9afee13a332dac66fda194db
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
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs";
6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
7 import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
9 import { FxAccountsStorageManager } from "resource://gre/modules/FxAccountsStorage.sys.mjs";
11 import {
12   ERRNO_INVALID_AUTH_TOKEN,
13   ERROR_AUTH_ERROR,
14   ERROR_INVALID_PARAMETER,
15   ERROR_NO_ACCOUNT,
16   ERROR_TO_GENERAL_ERROR_CLASS,
17   ERROR_UNKNOWN,
18   ERROR_UNVERIFIED_ACCOUNT,
19   FXA_PWDMGR_PLAINTEXT_FIELDS,
20   FXA_PWDMGR_REAUTH_ALLOWLIST,
21   FXA_PWDMGR_SECURE_FIELDS,
22   OAUTH_CLIENT_ID,
23   ON_ACCOUNT_STATE_CHANGE_NOTIFICATION,
24   ONLOGIN_NOTIFICATION,
25   ONLOGOUT_NOTIFICATION,
26   ON_PRELOGOUT_NOTIFICATION,
27   ONVERIFIED_NOTIFICATION,
28   ON_DEVICE_DISCONNECTED_NOTIFICATION,
29   POLL_SESSION,
30   PREF_ACCOUNT_ROOT,
31   PREF_LAST_FXA_USER,
32   SERVER_ERRNO_TO_ERROR,
33   log,
34   logPII,
35   logManager,
36 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
38 const lazy = {};
40 ChromeUtils.defineESModuleGetters(lazy, {
41   FxAccountsClient: "resource://gre/modules/FxAccountsClient.sys.mjs",
42   FxAccountsCommands: "resource://gre/modules/FxAccountsCommands.sys.mjs",
43   FxAccountsConfig: "resource://gre/modules/FxAccountsConfig.sys.mjs",
44   FxAccountsDevice: "resource://gre/modules/FxAccountsDevice.sys.mjs",
45   FxAccountsKeys: "resource://gre/modules/FxAccountsKeys.sys.mjs",
46   FxAccountsOAuth: "resource://gre/modules/FxAccountsOAuth.sys.mjs",
47   FxAccountsProfile: "resource://gre/modules/FxAccountsProfile.sys.mjs",
48   FxAccountsTelemetry: "resource://gre/modules/FxAccountsTelemetry.sys.mjs",
49 });
51 ChromeUtils.defineLazyGetter(lazy, "mpLocked", () => {
52   return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs")
53     .Utils.mpLocked;
54 });
56 ChromeUtils.defineLazyGetter(lazy, "ensureMPUnlocked", () => {
57   return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs")
58     .Utils.ensureMPUnlocked;
59 });
61 XPCOMUtils.defineLazyPreferenceGetter(
62   lazy,
63   "FXA_ENABLED",
64   "identity.fxaccounts.enabled",
65   true
68 XPCOMUtils.defineLazyPreferenceGetter(
69   lazy,
70   "oauthEnabled",
71   "identity.fxaccounts.oauth.enabled",
72   true
75 export const ERROR_INVALID_ACCOUNT_STATE = "ERROR_INVALID_ACCOUNT_STATE";
77 // An AccountState object holds all state related to one specific account.
78 // It is considered "private" to the FxAccounts modules.
79 // Only one AccountState is ever "current" in the FxAccountsInternal object -
80 // whenever a user logs out or logs in, the current AccountState is discarded,
81 // making it impossible for the wrong state or state data to be accidentally
82 // used.
83 // In addition, it has some promise-related helpers to ensure that if an
84 // attempt is made to resolve a promise on a "stale" state (eg, if an
85 // operation starts, but a different user logs in before the operation
86 // completes), the promise will be rejected.
87 // It is intended to be used thusly:
88 // somePromiseBasedFunction: function() {
89 //   let currentState = this.currentAccountState;
90 //   return someOtherPromiseFunction().then(
91 //     data => currentState.resolve(data)
92 //   );
93 // }
94 // If the state has changed between the function being called and the promise
95 // being resolved, the .resolve() call will actually be rejected.
96 export function AccountState(storageManager) {
97   this.storageManager = storageManager;
98   this.inFlightTokenRequests = new Map();
99   this.promiseInitialized = this.storageManager
100     .getAccountData()
101     .then(data => {
102       this.oauthTokens = data && data.oauthTokens ? data.oauthTokens : {};
103     })
104     .catch(err => {
105       log.error("Failed to initialize the storage manager", err);
106       // Things are going to fall apart, but not much we can do about it here.
107     });
110 AccountState.prototype = {
111   oauthTokens: null,
112   whenVerifiedDeferred: null,
113   whenKeysReadyDeferred: null,
115   // If the storage manager has been nuked then we are no longer current.
116   get isCurrent() {
117     return this.storageManager != null;
118   },
120   abort() {
121     if (this.whenVerifiedDeferred) {
122       this.whenVerifiedDeferred.reject(
123         new Error("Verification aborted; Another user signing in")
124       );
125       this.whenVerifiedDeferred = null;
126     }
127     if (this.whenKeysReadyDeferred) {
128       this.whenKeysReadyDeferred.reject(
129         new Error("Key fetching aborted; Another user signing in")
130       );
131       this.whenKeysReadyDeferred = null;
132     }
133     this.inFlightTokenRequests.clear();
134     return this.signOut();
135   },
137   // Clobber all cached data and write that empty data to storage.
138   async signOut() {
139     this.cert = null;
140     this.keyPair = null;
141     this.oauthTokens = null;
142     this.inFlightTokenRequests.clear();
144     // Avoid finalizing the storageManager multiple times (ie, .signOut()
145     // followed by .abort())
146     if (!this.storageManager) {
147       return;
148     }
149     const storageManager = this.storageManager;
150     this.storageManager = null;
152     await storageManager.deleteAccountData();
153     await storageManager.finalize();
154   },
156   // Get user account data. Optionally specify explicit field names to fetch
157   // (and note that if you require an in-memory field you *must* specify the
158   // field name(s).)
159   getUserAccountData(fieldNames = null) {
160     if (!this.isCurrent) {
161       return Promise.reject(new Error("Another user has signed in"));
162     }
163     return this.storageManager.getAccountData(fieldNames).then(result => {
164       return this.resolve(result);
165     });
166   },
168   async updateUserAccountData(updatedFields) {
169     if ("uid" in updatedFields) {
170       const existing = await this.getUserAccountData(["uid"]);
171       if (existing.uid != updatedFields.uid) {
172         throw new Error(
173           "The specified credentials aren't for the current user"
174         );
175       }
176       // We need to nuke uid as storage will complain if we try and
177       // update it (even when the value is the same)
178       updatedFields = Cu.cloneInto(updatedFields, {}); // clone it first
179       delete updatedFields.uid;
180     }
181     if (!this.isCurrent) {
182       return Promise.reject(new Error(ERROR_INVALID_ACCOUNT_STATE));
183     }
184     return this.storageManager.updateAccountData(updatedFields);
185   },
187   resolve(result) {
188     if (!this.isCurrent) {
189       log.info(
190         "An accountState promise was resolved, but was actually rejected" +
191           " due to the account state changing. This can happen if a new account signed in, or" +
192           " the account was signed out. Originally resolved with, ",
193         result
194       );
195       return Promise.reject(new Error(ERROR_INVALID_ACCOUNT_STATE));
196     }
197     return Promise.resolve(result);
198   },
200   reject(error) {
201     // It could be argued that we should just let it reject with the original
202     // error - but this runs the risk of the error being (eg) a 401, which
203     // might cause the consumer to attempt some remediation and cause other
204     // problems.
205     if (!this.isCurrent) {
206       log.info(
207         "An accountState promise was rejected, but we are ignoring that" +
208           " reason and rejecting it due to the account state changing. This can happen if" +
209           " a different account signed in or the account was signed out" +
210           " originally resolved with, ",
211         error
212       );
213       return Promise.reject(new Error(ERROR_INVALID_ACCOUNT_STATE));
214     }
215     return Promise.reject(error);
216   },
218   // Abstractions for storage of cached tokens - these are all sync, and don't
219   // handle revocation etc - it's just storage (and the storage itself is async,
220   // but we don't return the storage promises, so it *looks* sync)
221   // These functions are sync simply so we can handle "token races" - when there
222   // are multiple in-flight requests for the same scope, we can detect this
223   // and revoke the redundant token.
225   // A preamble for the cache helpers...
226   _cachePreamble() {
227     if (!this.isCurrent) {
228       throw new Error(ERROR_INVALID_ACCOUNT_STATE);
229     }
230   },
232   // Set a cached token. |tokenData| must have a 'token' element, but may also
233   // have additional fields.
234   // The 'get' functions below return the entire |tokenData| value.
235   setCachedToken(scopeArray, tokenData) {
236     this._cachePreamble();
237     if (!tokenData.token) {
238       throw new Error("No token");
239     }
240     let key = getScopeKey(scopeArray);
241     this.oauthTokens[key] = tokenData;
242     // And a background save...
243     this._persistCachedTokens();
244   },
246   // Return data for a cached token or null (or throws on bad state etc)
247   getCachedToken(scopeArray) {
248     this._cachePreamble();
249     let key = getScopeKey(scopeArray);
250     let result = this.oauthTokens[key];
251     if (result) {
252       // later we might want to check an expiry date - but we currently
253       // have no such concept, so just return it.
254       log.trace("getCachedToken returning cached token");
255       return result;
256     }
257     return null;
258   },
260   // Remove a cached token from the cache.  Does *not* revoke it from anywhere.
261   // Returns the entire token entry if found, null otherwise.
262   removeCachedToken(token) {
263     this._cachePreamble();
264     let data = this.oauthTokens;
265     for (let [key, tokenValue] of Object.entries(data)) {
266       if (tokenValue.token == token) {
267         delete data[key];
268         // And a background save...
269         this._persistCachedTokens();
270         return tokenValue;
271       }
272     }
273     return null;
274   },
276   // A hook-point for tests.  Returns a promise that's ignored in most cases
277   // (notable exceptions are tests and when we explicitly are saving the entire
278   // set of user data.)
279   _persistCachedTokens() {
280     this._cachePreamble();
281     return this.updateUserAccountData({ oauthTokens: this.oauthTokens }).catch(
282       err => {
283         log.error("Failed to update cached tokens", err);
284       }
285     );
286   },
289 /* Given an array of scopes, make a string key by normalizing. */
290 function getScopeKey(scopeArray) {
291   let normalizedScopes = scopeArray.map(item => item.toLowerCase());
292   return normalizedScopes.sort().join("|");
295 function getPropertyDescriptor(obj, prop) {
296   return (
297     Object.getOwnPropertyDescriptor(obj, prop) ||
298     getPropertyDescriptor(Object.getPrototypeOf(obj), prop)
299   );
303  * Copies properties from a given object to another object.
305  * @param from (object)
306  *        The object we read property descriptors from.
307  * @param to (object)
308  *        The object that we set property descriptors on.
309  * @param thisObj (object)
310  *        The object that will be used to .bind() all function properties we find to.
311  * @param keys ([...])
312  *        The names of all properties to be copied.
313  */
314 function copyObjectProperties(from, to, thisObj, keys) {
315   for (let prop of keys) {
316     // Look for the prop in the prototype chain.
317     let desc = getPropertyDescriptor(from, prop);
319     if (typeof desc.value == "function") {
320       desc.value = desc.value.bind(thisObj);
321     }
323     if (desc.get) {
324       desc.get = desc.get.bind(thisObj);
325     }
327     if (desc.set) {
328       desc.set = desc.set.bind(thisObj);
329     }
331     Object.defineProperty(to, prop, desc);
332   }
336  * The public API.
338  * TODO - *all* non-underscore stuff here should have sphinx docstrings so
339  * that docs magically appear on https://firefox-source-docs.mozilla.org/
340  * (although |./mach doc| is broken on windows (bug 1232403) and on Linux for
341  * markh (some obscure npm issue he gave up on) - so later...)
342  */
343 export class FxAccounts {
344   constructor(mocks = null) {
345     this._internal = new FxAccountsInternal();
346     if (mocks) {
347       // it's slightly unfortunate that we need to mock the main "internal" object
348       // before calling initialize, primarily so a mock `newAccountState` is in
349       // place before initialize calls it, but we need to initialize the
350       // "sub-object" mocks after. This can probably be fixed, but whatever...
351       copyObjectProperties(
352         mocks,
353         this._internal,
354         this._internal,
355         Object.keys(mocks).filter(key => !["device", "commands"].includes(key))
356       );
357     }
358     this._internal.initialize();
359     // allow mocking our "sub-objects" too.
360     if (mocks) {
361       for (let subobject of [
362         "currentAccountState",
363         "keys",
364         "fxaPushService",
365         "device",
366         "commands",
367       ]) {
368         if (typeof mocks[subobject] == "object") {
369           copyObjectProperties(
370             mocks[subobject],
371             this._internal[subobject],
372             this._internal[subobject],
373             Object.keys(mocks[subobject])
374           );
375         }
376       }
377     }
378   }
380   get commands() {
381     return this._internal.commands;
382   }
384   static get config() {
385     return lazy.FxAccountsConfig;
386   }
388   get device() {
389     return this._internal.device;
390   }
392   get keys() {
393     return this._internal.keys;
394   }
396   get telemetry() {
397     return this._internal.telemetry;
398   }
400   _withCurrentAccountState(func) {
401     return this._internal.withCurrentAccountState(func);
402   }
404   _withVerifiedAccountState(func) {
405     return this._internal.withVerifiedAccountState(func);
406   }
408   _withSessionToken(func, mustBeVerified = true) {
409     return this._internal.withSessionToken(func, mustBeVerified);
410   }
412   /**
413    * Returns an array listing all the OAuth clients connected to the
414    * authenticated user's account. This includes browsers and web sessions - no
415    * filtering is done of the set returned by the FxA server.
416    *
417    * @typedef {Object} AttachedClient
418    * @property {String} id - OAuth `client_id` of the client.
419    * @property {Number} lastAccessedDaysAgo - How many days ago the client last
420    *    accessed the FxA server APIs.
421    *
422    * @returns {Array.<AttachedClient>} A list of attached clients.
423    */
424   async listAttachedOAuthClients() {
425     // We expose last accessed times in 'days ago'
426     const ONE_DAY = 24 * 60 * 60 * 1000;
428     return this._withSessionToken(async sessionToken => {
429       const response = await this._internal.fxAccountsClient.attachedClients(
430         sessionToken
431       );
432       const attachedClients = response.body;
433       const timestamp = response.headers["x-timestamp"];
434       const now =
435         timestamp !== undefined
436           ? new Date(parseInt(timestamp, 10))
437           : Date.now();
438       return attachedClients.map(client => {
439         const daysAgo = client.lastAccessTime
440           ? Math.max(Math.floor((now - client.lastAccessTime) / ONE_DAY), 0)
441           : null;
442         return {
443           id: client.clientId,
444           lastAccessedDaysAgo: daysAgo,
445         };
446       });
447     });
448   }
450   /**
451    * Get an OAuth token for the user.
452    *
453    * @param options
454    *        {
455    *          scope: (string/array) the oauth scope(s) being requested. As a
456    *                 convenience, you may pass a string if only one scope is
457    *                 required, or an array of strings if multiple are needed.
458    *          ttl: (number) OAuth token TTL in seconds.
459    *        }
460    *
461    * @return Promise.<string | Error>
462    *        The promise resolves the oauth token as a string or rejects with
463    *        an error object ({error: ERROR, details: {}}) of the following:
464    *          INVALID_PARAMETER
465    *          NO_ACCOUNT
466    *          UNVERIFIED_ACCOUNT
467    *          NETWORK_ERROR
468    *          AUTH_ERROR
469    *          UNKNOWN_ERROR
470    */
471   async getOAuthToken(options = {}) {
472     try {
473       return await this._internal.getOAuthToken(options);
474     } catch (err) {
475       throw this._internal._errorToErrorClass(err);
476     }
477   }
479   /** Gets both the OAuth token and the users scoped keys for that token
480    * and verifies that both operations were done for the same user,
481    * preventing race conditions where a caller
482    * can get the key for one user, and the id of another if the user
483    * is rapidly switching between accounts
484    *
485    * @param options
486    *        {
487    *          scope: string the oauth scope being requested. This must
488    *          be a scope with an associated key, otherwise an error
489    *          will be thrown that the key is not available.
490    *          ttl: (number) OAuth token TTL in seconds
491    *        }
492    *
493    * @return Promise.<Object | Error>
494    * The promise resolve to both the access token being requested, and the scoped key
495    *        {
496    *         token: (string) access token
497    *         key: (object) the scoped key object
498    *        }
499    * The promise can reject, with one of the errors `getOAuthToken`, `FxAccountKeys.getKeyForScope`, or
500    * error if the user changed in-between operations
501    */
502   getOAuthTokenAndKey(options = {}) {
503     return this._withCurrentAccountState(async () => {
504       const key = await this.keys.getKeyForScope(options.scope);
505       const token = await this.getOAuthToken(options);
506       return { token, key };
507     });
508   }
510   /**
511    * Remove an OAuth token from the token cache. Callers should call this
512    * after they determine a token is invalid, so a new token will be fetched
513    * on the next call to getOAuthToken().
514    *
515    * @param options
516    *        {
517    *          token: (string) A previously fetched token.
518    *        }
519    * @return Promise.<undefined> This function will always resolve, even if
520    *         an unknown token is passed.
521    */
522   removeCachedOAuthToken(options) {
523     return this._internal.removeCachedOAuthToken(options);
524   }
526   /**
527    * Get details about the user currently signed in to Firefox Accounts.
528    *
529    * @return Promise
530    *        The promise resolves to the credentials object of the signed-in user:
531    *        {
532    *          email: String: The user's email address
533    *          uid: String: The user's unique id
534    *          verified: Boolean: email verification status
535    *          displayName: String or null if not known.
536    *          avatar: URL of the avatar for the user. May be the default
537    *                  avatar, or null in edge-cases (eg, if there's an account
538    *                  issue, etc
539    *          avatarDefault: boolean - whether `avatar` is specific to the user
540    *                         or the default avatar.
541    *        }
542    *
543    *        or null if no user is signed in. This function never fails except
544    *        in pathological cases (eg, file-system errors, etc)
545    */
546   getSignedInUser() {
547     // Note we don't return the session token, but use it to see if we
548     // should fetch the profile.
549     const ACCT_DATA_FIELDS = ["email", "uid", "verified", "sessionToken"];
550     const PROFILE_FIELDS = ["displayName", "avatar", "avatarDefault"];
551     return this._withCurrentAccountState(async currentState => {
552       const data = await currentState.getUserAccountData(ACCT_DATA_FIELDS);
553       if (!data) {
554         return null;
555       }
556       if (!lazy.FXA_ENABLED) {
557         await this.signOut();
558         return null;
559       }
560       if (!this._internal.isUserEmailVerified(data) && !lazy.oauthEnabled) {
561         // If the email is not verified, start polling for verification,
562         // but return null right away.  We don't want to return a promise
563         // that might not be fulfilled for a long time.
564         this._internal.startVerifiedCheck(data);
565       }
567       let profileData = null;
568       if (data.sessionToken) {
569         delete data.sessionToken;
570         try {
571           profileData = await this._internal.profile.getProfile();
572         } catch (error) {
573           log.error("Could not retrieve profile data", error);
574         }
575       }
576       for (let field of PROFILE_FIELDS) {
577         data[field] = profileData ? profileData[field] : null;
578       }
579       // and email is a special case - if we have profile data we prefer the
580       // email from that, as the email we stored for the account itself might
581       // not have been updated if the email changed since the user signed in.
582       if (profileData && profileData.email) {
583         data.email = profileData.email;
584       }
585       return data;
586     });
587   }
589   /**
590    * Checks the status of the account. Resolves with Promise<boolean>, where
591    * true indicates the account status is OK and false indicates there's some
592    * issue with the account - either that there's no user currently signed in,
593    * the entire account has been deleted (in which case there will be no user
594    * signed in after this call returns), or that the user must reauthenticate (in
595    * which case `this.hasLocalSession()` will return `false` after this call
596    * returns).
597    *
598    * Typically used when some external code which uses, for example, oauth tokens
599    * received a 401 error using the token, or that this external code has some
600    * other reason to believe the account status may be bad. Note that this will
601    * be called automatically in many cases - for example, if calls to fetch the
602    * profile, or fetch keys, etc return a 401, there's no need to call this
603    * function.
604    *
605    * Because this hits the server, you should only call this method when you have
606    * good reason to believe the session very recently became invalid (eg, because
607    * you saw an auth related exception from a remote service.)
608    */
609   checkAccountStatus() {
610     // Note that we don't use _withCurrentAccountState here because that will
611     // cause an exception to be thrown if we end up signing out due to the
612     // account not existing, which isn't what we want here.
613     let state = this._internal.currentAccountState;
614     return this._internal.checkAccountStatus(state);
615   }
617   /**
618    * Checks if we have a valid local session state for the current account.
619    *
620    * @return Promise
621    *        Resolves with a boolean, with true indicating that we appear to
622    *        have a valid local session, or false if we need to reauthenticate
623    *        with the content server to obtain one.
624    *        Note that this only checks local state, although typically that's
625    *        OK, because we drop the local session information whenever we detect
626    *        we are in this state. However, see checkAccountStatus() for a way to
627    *        check the account and session status with the server, which can be
628    *        considered the canonical, albiet expensive, way to determine the
629    *        status of the account.
630    */
631   hasLocalSession() {
632     return this._withCurrentAccountState(async state => {
633       let data = await state.getUserAccountData(["sessionToken"]);
634       return !!(data && data.sessionToken);
635     });
636   }
638   /** Returns a promise that resolves to true if we can currently connect (ie,
639    *  sign in, or re-connect after a password change) to a Firefox Account.
640    *  If this returns false, the caller can assume that some UI was shown
641    *  which tells the user why we could not connect.
642    *
643    *  Currently, the primary password being locked is the only reason why
644    *  this returns false, and in this scenario, the primary password unlock
645    *  dialog will have been shown.
646    *
647    *  This currently doesn't need to return a promise, but does so that
648    *  future enhancements, such as other explanatory UI which requires
649    *  async can work without modification of the call-sites.
650    */
651   static canConnectAccount() {
652     return Promise.resolve(!lazy.mpLocked() || lazy.ensureMPUnlocked());
653   }
655   /**
656    * Send a message to a set of devices in the same account
657    *
658    * @param deviceIds: (null/string/array) The device IDs to send the message to.
659    *                   If null, will be sent to all devices.
660    *
661    * @param excludedIds: (null/string/array) If deviceIds is null, this may
662    *                     list device IDs which should not receive the message.
663    *
664    * @param payload: (object) The payload, which will be JSON.stringified.
665    *
666    * @param TTL: How long the message should be retained before it is discarded.
667    */
668   // XXX - used only by sync to tell other devices that the clients collection
669   // has changed so they should sync asap. The API here is somewhat vague (ie,
670   // "an object"), but to be useful across devices, the payload really needs
671   // formalizing. We should try and do something better here.
672   notifyDevices(deviceIds, excludedIds, payload, TTL) {
673     return this._internal.notifyDevices(deviceIds, excludedIds, payload, TTL);
674   }
676   /**
677    * Resend the verification email for the currently signed-in user.
678    *
679    */
680   resendVerificationEmail() {
681     return this._withSessionToken((token, currentState) => {
682       this._internal.startPollEmailStatus(currentState, token, "start");
683       return this._internal.fxAccountsClient.resendVerificationEmail(token);
684     }, false);
685   }
687   async signOut(localOnly) {
688     // Note that we do not use _withCurrentAccountState here, otherwise we
689     // end up with an exception due to the user signing out before the call is
690     // complete - but that's the entire point of this method :)
691     return this._internal.signOut(localOnly);
692   }
694   // XXX - we should consider killing this - the only reason it is public is
695   // so that sync can change it when it notices the device name being changed,
696   // and that could probably be replaced with a pref observer.
697   updateDeviceRegistration() {
698     return this._withCurrentAccountState(_ => {
699       return this._internal.updateDeviceRegistration();
700     });
701   }
703   // we should try and kill this too.
704   whenVerified(data) {
705     return this._withCurrentAccountState(_ => {
706       return this._internal.whenVerified(data);
707     });
708   }
710   /**
711    * Generate a log file for the FxA action that just completed
712    * and refresh the input & output streams.
713    */
714   async flushLogFile() {
715     const logType = await logManager.resetFileLog();
716     if (logType == logManager.ERROR_LOG_WRITTEN) {
717       console.error(
718         "FxA encountered an error - see about:sync-log for the log file."
719       );
720     }
721     Services.obs.notifyObservers(null, "service:log-manager:flush-log-file");
722   }
725 var FxAccountsInternal = function () {};
728  * The internal API's prototype.
729  */
730 FxAccountsInternal.prototype = {
731   // Make a local copy of this constant so we can mock it in testing
732   POLL_SESSION,
734   // The timeout (in ms) we use to poll for a verified mail for the first
735   // VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD minutes if the user has
736   // logged-in in this session.
737   VERIFICATION_POLL_TIMEOUT_INITIAL: 60000, // 1 minute.
738   // All the other cases (> 5 min, on restart etc).
739   VERIFICATION_POLL_TIMEOUT_SUBSEQUENT: 5 * 60000, // 5 minutes.
740   // After X minutes, the polling will slow down to _SUBSEQUENT if we have
741   // logged-in in this session.
742   VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD: 5,
744   _fxAccountsClient: null,
746   // All significant initialization should be done in this initialize() method
747   // to help with our mocking story.
748   initialize() {
749     ChromeUtils.defineLazyGetter(this, "fxaPushService", function () {
750       return Cc["@mozilla.org/fxaccounts/push;1"].getService(
751         Ci.nsISupports
752       ).wrappedJSObject;
753     });
755     this.keys = new lazy.FxAccountsKeys(this);
757     if (!this.observerPreloads) {
758       // A registry of promise-returning functions that `notifyObservers` should
759       // call before sending notifications. Primarily used so parts of Firefox
760       // which have yet to load for performance reasons can be force-loaded, and
761       // thus not miss notifications.
762       this.observerPreloads = [
763         // Sync
764         () => {
765           let { Weave } = ChromeUtils.importESModule(
766             "resource://services-sync/main.sys.mjs"
767           );
768           return Weave.Service.promiseInitialized;
769         },
770       ];
771     }
773     this.currentTimer = null;
774     // This object holds details about, and storage for, the current user. It
775     // is replaced when a different user signs in. Instead of using it directly,
776     // you should try and use `withCurrentAccountState`.
777     this.currentAccountState = this.newAccountState();
778   },
780   async withCurrentAccountState(func) {
781     const state = this.currentAccountState;
782     let result;
783     try {
784       result = await func(state);
785     } catch (ex) {
786       return state.reject(ex);
787     }
788     return state.resolve(result);
789   },
791   async withVerifiedAccountState(func) {
792     return this.withCurrentAccountState(async state => {
793       let data = await state.getUserAccountData();
794       if (!data) {
795         // No signed-in user
796         throw this._error(ERROR_NO_ACCOUNT);
797       }
799       if (!this.isUserEmailVerified(data)) {
800         // Signed-in user has not verified email
801         throw this._error(ERROR_UNVERIFIED_ACCOUNT);
802       }
803       return func(state);
804     });
805   },
807   async withSessionToken(func, mustBeVerified = true) {
808     const state = this.currentAccountState;
809     let data = await state.getUserAccountData();
810     if (!data) {
811       // No signed-in user
812       throw this._error(ERROR_NO_ACCOUNT);
813     }
815     if (mustBeVerified && !this.isUserEmailVerified(data)) {
816       // Signed-in user has not verified email
817       throw this._error(ERROR_UNVERIFIED_ACCOUNT);
818     }
820     if (!data.sessionToken) {
821       throw this._error(ERROR_AUTH_ERROR, "no session token");
822     }
823     try {
824       // Anyone who needs the session token is going to send it to the server,
825       // so there's a chance we'll see an auth related error - so handle that
826       // here rather than requiring each caller to remember to.
827       let result = await func(data.sessionToken, state);
828       return state.resolve(result);
829     } catch (err) {
830       return this._handleTokenError(err);
831     }
832   },
834   get fxAccountsClient() {
835     if (!this._fxAccountsClient) {
836       this._fxAccountsClient = new lazy.FxAccountsClient();
837     }
838     return this._fxAccountsClient;
839   },
841   // The profile object used to fetch the actual user profile.
842   _profile: null,
843   get profile() {
844     if (!this._profile) {
845       let profileServerUrl = Services.urlFormatter.formatURLPref(
846         "identity.fxaccounts.remote.profile.uri"
847       );
848       this._profile = new lazy.FxAccountsProfile({
849         fxa: this,
850         profileServerUrl,
851       });
852     }
853     return this._profile;
854   },
856   _commands: null,
857   get commands() {
858     if (!this._commands) {
859       this._commands = new lazy.FxAccountsCommands(this);
860     }
861     return this._commands;
862   },
864   _device: null,
865   get device() {
866     if (!this._device) {
867       this._device = new lazy.FxAccountsDevice(this);
868     }
869     return this._device;
870   },
872   _oauth: null,
873   get oauth() {
874     if (!this._oauth) {
875       this._oauth = new lazy.FxAccountsOAuth(this.fxAccountsClient, this.keys);
876     }
877     return this._oauth;
878   },
880   _telemetry: null,
881   get telemetry() {
882     if (!this._telemetry) {
883       this._telemetry = new lazy.FxAccountsTelemetry(this);
884     }
885     return this._telemetry;
886   },
888   beginOAuthFlow(scopes) {
889     return this.oauth.beginOAuthFlow(scopes);
890   },
892   completeOAuthFlow(sessionToken, code, state) {
893     return this.oauth.completeOAuthFlow(sessionToken, code, state);
894   },
896   setScopedKeys(scopedKeys) {
897     return this.keys.setScopedKeys(scopedKeys);
898   },
900   // A hook-point for tests who may want a mocked AccountState or mocked storage.
901   newAccountState(credentials) {
902     let storage = new FxAccountsStorageManager();
903     storage.initialize(credentials);
904     return new AccountState(storage);
905   },
907   notifyDevices(deviceIds, excludedIds, payload, TTL) {
908     if (typeof deviceIds == "string") {
909       deviceIds = [deviceIds];
910     }
911     return this.withSessionToken(sessionToken => {
912       return this.fxAccountsClient.notifyDevices(
913         sessionToken,
914         deviceIds,
915         excludedIds,
916         payload,
917         TTL
918       );
919     });
920   },
922   /**
923    * Return the current time in milliseconds as an integer.  Allows tests to
924    * manipulate the date to simulate token expiration.
925    */
926   now() {
927     return this.fxAccountsClient.now();
928   },
930   /**
931    * Return clock offset in milliseconds, as reported by the fxAccountsClient.
932    * This can be overridden for testing.
933    *
934    * The offset is the number of milliseconds that must be added to the client
935    * clock to make it equal to the server clock.  For example, if the client is
936    * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
937    */
938   get localtimeOffsetMsec() {
939     return this.fxAccountsClient.localtimeOffsetMsec;
940   },
942   /**
943    * Ask the server whether the user's email has been verified
944    */
945   checkEmailStatus: function checkEmailStatus(sessionToken, options = {}) {
946     if (!sessionToken) {
947       return Promise.reject(
948         new Error("checkEmailStatus called without a session token")
949       );
950     }
951     return this.fxAccountsClient
952       .recoveryEmailStatus(sessionToken, options)
953       .catch(error => this._handleTokenError(error));
954   },
956   // set() makes sure that polling is happening, if necessary.
957   // get() does not wait for verification, and returns an object even if
958   // unverified. The caller of get() must check .verified .
959   // The "fxaccounts:onverified" event will fire only when the verified
960   // state goes from false to true, so callers must register their observer
961   // and then call get(). In particular, it will not fire when the account
962   // was found to be verified in a previous boot: if our stored state says
963   // the account is verified, the event will never fire. So callers must do:
964   //   register notification observer (go)
965   //   userdata = get()
966   //   if (userdata.verified()) {go()}
968   /**
969    * Set the current user signed in to Firefox Accounts.
970    *
971    * @param credentials
972    *        The credentials object obtained by logging in or creating
973    *        an account on the FxA server:
974    *        {
975    *          authAt: The time (seconds since epoch) that this record was
976    *                  authenticated
977    *          email: The users email address
978    *          keyFetchToken: a keyFetchToken which has not yet been used
979    *          sessionToken: Session for the FxA server
980    *          uid: The user's unique id
981    *          unwrapBKey: used to unwrap kB, derived locally from the
982    *                      password (not revealed to the FxA server)
983    *          verified: true/false
984    *        }
985    * @return Promise
986    *         The promise resolves to null when the data is saved
987    *         successfully and is rejected on error.
988    */
989   async setSignedInUser(credentials) {
990     if (!lazy.FXA_ENABLED) {
991       throw new Error("Cannot call setSignedInUser when FxA is disabled.");
992     }
993     for (const pref of Services.prefs.getChildList(PREF_ACCOUNT_ROOT)) {
994       Services.prefs.clearUserPref(pref);
995     }
996     log.debug("setSignedInUser - aborting any existing flows");
997     const signedInUser = await this.currentAccountState.getUserAccountData();
998     if (signedInUser) {
999       await this._signOutServer(
1000         signedInUser.sessionToken,
1001         signedInUser.oauthTokens
1002       );
1003     }
1004     await this.abortExistingFlow();
1005     const currentAccountState = (this.currentAccountState =
1006       this.newAccountState(
1007         Cu.cloneInto(credentials, {}) // Pass a clone of the credentials object.
1008       ));
1009     // This promise waits for storage, but not for verification.
1010     // We're telling the caller that this is durable now (although is that
1011     // really something we should commit to? Why not let the write happen in
1012     // the background? Already does for updateAccountData ;)
1013     await currentAccountState.promiseInitialized;
1014     // Starting point for polling if new user
1015     if (!this.isUserEmailVerified(credentials) && !lazy.oauthEnabled) {
1016       this.startVerifiedCheck(credentials);
1017     }
1018     await this.notifyObservers(ONLOGIN_NOTIFICATION);
1019     await this.updateDeviceRegistration();
1020     return currentAccountState.resolve();
1021   },
1023   /**
1024    * Update account data for the currently signed in user.
1025    *
1026    * @param credentials
1027    *        The credentials object containing the fields to be updated.
1028    *        This object must contain the |uid| field and it must
1029    *        match the currently signed in user.
1030    */
1031   updateUserAccountData(credentials) {
1032     log.debug(
1033       "updateUserAccountData called with fields",
1034       Object.keys(credentials)
1035     );
1036     if (logPII()) {
1037       log.debug("updateUserAccountData called with data", credentials);
1038     }
1039     let currentAccountState = this.currentAccountState;
1040     return currentAccountState.promiseInitialized.then(() => {
1041       if (!credentials.uid) {
1042         throw new Error("The specified credentials have no uid");
1043       }
1044       return currentAccountState.updateUserAccountData(credentials);
1045     });
1046   },
1048   /*
1049    * Reset state such that any previous flow is canceled.
1050    */
1051   abortExistingFlow() {
1052     if (this.currentTimer) {
1053       log.debug("Polling aborted; Another user signing in");
1054       clearTimeout(this.currentTimer);
1055       this.currentTimer = 0;
1056     }
1057     if (this._profile) {
1058       this._profile.tearDown();
1059       this._profile = null;
1060     }
1061     if (this._commands) {
1062       this._commands = null;
1063     }
1064     if (this._device) {
1065       this._device.reset();
1066     }
1067     // We "abort" the accountState and assume our caller is about to throw it
1068     // away and replace it with a new one.
1069     return this.currentAccountState.abort();
1070   },
1072   async checkVerificationStatus() {
1073     log.trace("checkVerificationStatus");
1074     let state = this.currentAccountState;
1075     let data = await state.getUserAccountData();
1076     if (!data) {
1077       log.trace("checkVerificationStatus - no user data");
1078       return null;
1079     }
1081     // Always check the verification status, even if the local state indicates
1082     // we're already verified. If the user changed their password, the check
1083     // will fail, and we'll enter the reauth state.
1084     log.trace("checkVerificationStatus - forcing verification status check");
1085     return this.startPollEmailStatus(state, data.sessionToken, "push");
1086   },
1088   /** Destroyes an OAuth Token by sending a request to the FxA server
1089    * @param { Object } tokenData: The token's data, with `tokenData.token` being the token itself
1090    **/
1091   destroyOAuthToken(tokenData) {
1092     return this.fxAccountsClient.oauthDestroy(OAUTH_CLIENT_ID, tokenData.token);
1093   },
1095   _destroyAllOAuthTokens(tokenInfos) {
1096     if (!tokenInfos) {
1097       return Promise.resolve();
1098     }
1099     // let's just destroy them all in parallel...
1100     let promises = [];
1101     for (let tokenInfo of Object.values(tokenInfos)) {
1102       promises.push(this.destroyOAuthToken(tokenInfo));
1103     }
1104     return Promise.all(promises);
1105   },
1107   async signOut(localOnly) {
1108     let sessionToken;
1109     let tokensToRevoke;
1110     const data = await this.currentAccountState.getUserAccountData();
1111     // Save the sessionToken, tokens before resetting them in _signOutLocal().
1112     if (data) {
1113       sessionToken = data.sessionToken;
1114       tokensToRevoke = data.oauthTokens;
1115     }
1116     await this.notifyObservers(ON_PRELOGOUT_NOTIFICATION);
1117     await this._signOutLocal();
1118     if (!localOnly) {
1119       // Do this in the background so *any* slow request won't
1120       // block the local sign out.
1121       Services.tm.dispatchToMainThread(async () => {
1122         await this._signOutServer(sessionToken, tokensToRevoke);
1123         lazy.FxAccountsConfig.resetConfigURLs();
1124         this.notifyObservers("testhelper-fxa-signout-complete");
1125       });
1126     } else {
1127       // We want to do this either way -- but if we're signing out remotely we
1128       // need to wait until we destroy the oauth tokens if we want that to succeed.
1129       lazy.FxAccountsConfig.resetConfigURLs();
1130     }
1131     return this.notifyObservers(ONLOGOUT_NOTIFICATION);
1132   },
1134   async _signOutLocal() {
1135     for (const pref of Services.prefs.getChildList(PREF_ACCOUNT_ROOT)) {
1136       Services.prefs.clearUserPref(pref);
1137     }
1138     await this.currentAccountState.signOut();
1139     // this "aborts" this.currentAccountState but doesn't make a new one.
1140     await this.abortExistingFlow();
1141     this.currentAccountState = this.newAccountState();
1142     return this.currentAccountState.promiseInitialized;
1143   },
1145   async _signOutServer(sessionToken, tokensToRevoke) {
1146     log.debug("Unsubscribing from FxA push.");
1147     try {
1148       await this.fxaPushService.unsubscribe();
1149     } catch (err) {
1150       log.error("Could not unsubscribe from push.", err);
1151     }
1152     if (sessionToken) {
1153       log.debug("Destroying session and device.");
1154       try {
1155         await this.fxAccountsClient.signOut(sessionToken, { service: "sync" });
1156       } catch (err) {
1157         log.error("Error during remote sign out of Firefox Accounts", err);
1158       }
1159     } else {
1160       log.warn("Missing session token; skipping remote sign out");
1161     }
1162     log.debug("Destroying all OAuth tokens.");
1163     try {
1164       await this._destroyAllOAuthTokens(tokensToRevoke);
1165     } catch (err) {
1166       log.error("Error during destruction of oauth tokens during signout", err);
1167     }
1168   },
1170   getUserAccountData(fieldNames = null) {
1171     return this.currentAccountState.getUserAccountData(fieldNames);
1172   },
1174   isUserEmailVerified: function isUserEmailVerified(data) {
1175     return !!(data && data.verified);
1176   },
1178   /**
1179    * Setup for and if necessary do email verification polling.
1180    */
1181   loadAndPoll() {
1182     let currentState = this.currentAccountState;
1183     return currentState.getUserAccountData().then(data => {
1184       if (data) {
1185         if (!this.isUserEmailVerified(data)) {
1186           this.startPollEmailStatus(
1187             currentState,
1188             data.sessionToken,
1189             "browser-startup"
1190           );
1191         }
1192       }
1193       return data;
1194     });
1195   },
1197   startVerifiedCheck(data) {
1198     log.debug("startVerifiedCheck", data && data.verified);
1199     if (logPII()) {
1200       log.debug("startVerifiedCheck with user data", data);
1201     }
1203     // Get us to the verified state. This returns a promise that will fire when
1204     // verification is complete.
1206     // The callers of startVerifiedCheck never consume a returned promise (ie,
1207     // this is simply kicking off a background fetch) so we must add a rejection
1208     // handler to avoid runtime warnings about the rejection not being handled.
1209     this.whenVerified(data).catch(err =>
1210       log.info("startVerifiedCheck promise was rejected: " + err)
1211     );
1212   },
1214   whenVerified(data) {
1215     let currentState = this.currentAccountState;
1216     if (data.verified) {
1217       log.debug("already verified");
1218       return currentState.resolve(data);
1219     }
1220     if (!currentState.whenVerifiedDeferred) {
1221       log.debug("whenVerified promise starts polling for verified email");
1222       this.startPollEmailStatus(currentState, data.sessionToken, "start");
1223     }
1224     return currentState.whenVerifiedDeferred.promise.then(result =>
1225       currentState.resolve(result)
1226     );
1227   },
1229   async notifyObservers(topic, data) {
1230     for (let f of this.observerPreloads) {
1231       try {
1232         await f();
1233       } catch (O_o) {}
1234     }
1235     log.debug("Notifying observers of " + topic);
1236     Services.obs.notifyObservers(null, topic, data);
1237   },
1239   startPollEmailStatus(currentState, sessionToken, why) {
1240     log.debug("entering startPollEmailStatus: " + why);
1241     // If we were already polling, stop and start again.  This could happen
1242     // if the user requested the verification email to be resent while we
1243     // were already polling for receipt of an earlier email.
1244     if (this.currentTimer) {
1245       log.debug(
1246         "startPollEmailStatus starting while existing timer is running"
1247       );
1248       clearTimeout(this.currentTimer);
1249       this.currentTimer = null;
1250     }
1252     this.pollStartDate = Date.now();
1253     if (!currentState.whenVerifiedDeferred) {
1254       currentState.whenVerifiedDeferred = Promise.withResolvers();
1255       // This deferred might not end up with any handlers (eg, if sync
1256       // is yet to start up.)  This might cause "A promise chain failed to
1257       // handle a rejection" messages, so add an error handler directly
1258       // on the promise to log the error.
1259       currentState.whenVerifiedDeferred.promise.then(
1260         () => {
1261           log.info("the user became verified");
1262           // We are now ready for business. This should only be invoked once
1263           // per setSignedInUser(), regardless of whether we've rebooted since
1264           // setSignedInUser() was called.
1265           this.notifyObservers(ONVERIFIED_NOTIFICATION);
1266         },
1267         err => {
1268           log.info("the wait for user verification was stopped: " + err);
1269         }
1270       );
1271     }
1272     return this.pollEmailStatus(currentState, sessionToken, why);
1273   },
1275   // We return a promise for testing only. Other callers can ignore this,
1276   // since verification polling continues in the background.
1277   async pollEmailStatus(currentState, sessionToken, why) {
1278     log.debug("entering pollEmailStatus: " + why);
1279     let nextPollMs;
1280     try {
1281       const response = await this.checkEmailStatus(sessionToken, {
1282         reason: why,
1283       });
1284       log.debug("checkEmailStatus -> " + JSON.stringify(response));
1285       if (response && response.verified) {
1286         await this.onPollEmailSuccess(currentState);
1287         return;
1288       }
1289     } catch (error) {
1290       if (error && error.code && error.code == 401) {
1291         let error = new Error("Verification status check failed");
1292         this._rejectWhenVerified(currentState, error);
1293         return;
1294       }
1295       if (error && error.retryAfter) {
1296         // If the server told us to back off, back off the requested amount.
1297         nextPollMs = (error.retryAfter + 3) * 1000;
1298         log.warn(
1299           `the server rejected our email status check and told us to try again in ${nextPollMs}ms`
1300         );
1301       } else {
1302         log.error(`checkEmailStatus failed to poll`, error);
1303       }
1304     }
1305     if (why == "push") {
1306       return;
1307     }
1308     let pollDuration = Date.now() - this.pollStartDate;
1309     // Polling session expired.
1310     if (pollDuration >= this.POLL_SESSION) {
1311       if (currentState.whenVerifiedDeferred) {
1312         let error = new Error("User email verification timed out.");
1313         this._rejectWhenVerified(currentState, error);
1314       }
1315       log.debug("polling session exceeded, giving up");
1316       return;
1317     }
1318     // Poll email status again after a short delay.
1319     if (nextPollMs === undefined) {
1320       let currentMinute = Math.ceil(pollDuration / 60000);
1321       nextPollMs =
1322         why == "start" &&
1323         currentMinute < this.VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD
1324           ? this.VERIFICATION_POLL_TIMEOUT_INITIAL
1325           : this.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT;
1326     }
1327     this._scheduleNextPollEmailStatus(
1328       currentState,
1329       sessionToken,
1330       nextPollMs,
1331       why
1332     );
1333   },
1335   // Easy-to-mock testable method
1336   _scheduleNextPollEmailStatus(currentState, sessionToken, nextPollMs, why) {
1337     log.debug("polling with timeout = " + nextPollMs);
1338     this.currentTimer = setTimeout(() => {
1339       this.pollEmailStatus(currentState, sessionToken, why);
1340     }, nextPollMs);
1341   },
1343   async onPollEmailSuccess(currentState) {
1344     try {
1345       await currentState.updateUserAccountData({ verified: true });
1346       const accountData = await currentState.getUserAccountData();
1347       this._setLastUserPref(accountData.email);
1348       // Now that the user is verified, we can proceed to fetch keys
1349       if (currentState.whenVerifiedDeferred) {
1350         currentState.whenVerifiedDeferred.resolve(accountData);
1351         delete currentState.whenVerifiedDeferred;
1352       }
1353     } catch (e) {
1354       log.error(e);
1355     }
1356   },
1358   _rejectWhenVerified(currentState, error) {
1359     currentState.whenVerifiedDeferred.reject(error);
1360     delete currentState.whenVerifiedDeferred;
1361   },
1363   /**
1364    * Does the actual fetch of an oauth token for getOAuthToken()
1365    * using the account session token.
1366    *
1367    * It's split out into a separate method so that we can easily
1368    * stash in-flight calls in a cache.
1369    *
1370    * @param {String} scopeString
1371    * @param {Number} ttl
1372    * @returns {Promise<string>}
1373    * @private
1374    */
1375   async _doTokenFetchWithSessionToken(sessionToken, scopeString, ttl) {
1376     const result = await this.fxAccountsClient.accessTokenWithSessionToken(
1377       sessionToken,
1378       OAUTH_CLIENT_ID,
1379       scopeString,
1380       ttl
1381     );
1382     return result.access_token;
1383   },
1385   getOAuthToken(options = {}) {
1386     log.debug("getOAuthToken enter");
1387     let scope = options.scope;
1388     if (typeof scope === "string") {
1389       scope = [scope];
1390     }
1392     if (!scope || !scope.length) {
1393       return Promise.reject(
1394         this._error(
1395           ERROR_INVALID_PARAMETER,
1396           "Missing or invalid 'scope' option"
1397         )
1398       );
1399     }
1401     return this.withSessionToken(async (sessionToken, currentState) => {
1402       // Early exit for a cached token.
1403       let cached = currentState.getCachedToken(scope);
1404       if (cached) {
1405         log.debug("getOAuthToken returning a cached token");
1406         return cached.token;
1407       }
1409       // Build the string we use in our "inflight" map and that we send to the
1410       // server. Because it's used as a key in the map we sort the scopes.
1411       let scopeString = scope.sort().join(" ");
1413       // We keep a map of in-flight requests to avoid multiple promise-based
1414       // consumers concurrently requesting the same token.
1415       let maybeInFlight = currentState.inFlightTokenRequests.get(scopeString);
1416       if (maybeInFlight) {
1417         log.debug("getOAuthToken has an in-flight request for this scope");
1418         return maybeInFlight;
1419       }
1421       // We need to start a new fetch and stick the promise in our in-flight map
1422       // and remove it when it resolves.
1423       let promise = this._doTokenFetchWithSessionToken(
1424         sessionToken,
1425         scopeString,
1426         options.ttl
1427       )
1428         .then(token => {
1429           // As a sanity check, ensure something else hasn't raced getting a token
1430           // of the same scope. If something has we just make noise rather than
1431           // taking any concrete action because it should never actually happen.
1432           if (currentState.getCachedToken(scope)) {
1433             log.error(`detected a race for oauth token with scope ${scope}`);
1434           }
1435           // If we got one, cache it.
1436           if (token) {
1437             let entry = { token };
1438             currentState.setCachedToken(scope, entry);
1439           }
1440           return token;
1441         })
1442         .finally(() => {
1443           // Remove ourself from the in-flight map. There's no need to check the
1444           // result of .delete() to handle a signout race, because setCachedToken
1445           // above will fail in that case and cause the entire call to fail.
1446           currentState.inFlightTokenRequests.delete(scopeString);
1447         });
1449       currentState.inFlightTokenRequests.set(scopeString, promise);
1450       return promise;
1451     });
1452   },
1454   /**
1455    * Remove an OAuth token from the token cache
1456    * and makes a network request to FxA server to destroy the token.
1457    *
1458    * @param options
1459    *        {
1460    *          token: (string) A previously fetched token.
1461    *        }
1462    * @return Promise.<undefined> This function will always resolve, even if
1463    *         an unknown token is passed.
1464    */
1465   removeCachedOAuthToken(options) {
1466     if (!options.token || typeof options.token !== "string") {
1467       throw this._error(
1468         ERROR_INVALID_PARAMETER,
1469         "Missing or invalid 'token' option"
1470       );
1471     }
1472     return this.withCurrentAccountState(currentState => {
1473       let existing = currentState.removeCachedToken(options.token);
1474       if (existing) {
1475         // background destroy.
1476         this.destroyOAuthToken(existing).catch(err => {
1477           log.warn("FxA failed to revoke a cached token", err);
1478         });
1479       }
1480     });
1481   },
1483   /** Sets the user to be verified in the account state,
1484    * This prevents any polling for the user's verification state from the FxA server
1485    **/
1486   async setUserVerified() {
1487     await this.withCurrentAccountState(async currentState => {
1488       const userData = await currentState.getUserAccountData();
1489       if (!userData.verified) {
1490         await currentState.updateUserAccountData({ verified: true });
1491       }
1492     });
1493     await this.notifyObservers(ONVERIFIED_NOTIFICATION);
1494   },
1496   async _getVerifiedAccountOrReject() {
1497     let data = await this.currentAccountState.getUserAccountData();
1498     if (!data) {
1499       // No signed-in user
1500       throw this._error(ERROR_NO_ACCOUNT);
1501     }
1502     if (!this.isUserEmailVerified(data)) {
1503       // Signed-in user has not verified email
1504       throw this._error(ERROR_UNVERIFIED_ACCOUNT);
1505     }
1506     return data;
1507   },
1509   // _handle* methods used by push, used when the account/device status is
1510   // changed on a different device.
1511   async _handleAccountDestroyed(uid) {
1512     let state = this.currentAccountState;
1513     const accountData = await state.getUserAccountData();
1514     const localUid = accountData ? accountData.uid : null;
1515     if (!localUid) {
1516       log.info(
1517         `Account destroyed push notification received, but we're already logged-out`
1518       );
1519       return null;
1520     }
1521     if (uid == localUid) {
1522       const data = JSON.stringify({ isLocalDevice: true });
1523       await this.notifyObservers(ON_DEVICE_DISCONNECTED_NOTIFICATION, data);
1524       return this.signOut(true);
1525     }
1526     log.info(
1527       `The destroyed account uid doesn't match with the local uid. ` +
1528         `Local: ${localUid}, account uid destroyed: ${uid}`
1529     );
1530     return null;
1531   },
1533   async _handleDeviceDisconnection(deviceId) {
1534     let state = this.currentAccountState;
1535     const accountData = await state.getUserAccountData();
1536     if (!accountData || !accountData.device) {
1537       // Nothing we can do here.
1538       return;
1539     }
1540     const localDeviceId = accountData.device.id;
1541     const isLocalDevice = deviceId == localDeviceId;
1542     if (isLocalDevice) {
1543       this.signOut(true);
1544     }
1545     const data = JSON.stringify({ isLocalDevice });
1546     await this.notifyObservers(ON_DEVICE_DISCONNECTED_NOTIFICATION, data);
1547   },
1549   _setLastUserPref(newEmail) {
1550     Services.prefs.setStringPref(
1551       PREF_LAST_FXA_USER,
1552       CryptoUtils.sha256Base64(newEmail)
1553     );
1554   },
1556   async _handleEmailUpdated(newEmail) {
1557     this._setLastUserPref(newEmail);
1558     await this.currentAccountState.updateUserAccountData({ email: newEmail });
1559   },
1561   /*
1562    * Coerce an error into one of the general error cases:
1563    *          NETWORK_ERROR
1564    *          AUTH_ERROR
1565    *          UNKNOWN_ERROR
1566    *
1567    * These errors will pass through:
1568    *          INVALID_PARAMETER
1569    *          NO_ACCOUNT
1570    *          UNVERIFIED_ACCOUNT
1571    */
1572   _errorToErrorClass(aError) {
1573     if (aError.errno) {
1574       let error = SERVER_ERRNO_TO_ERROR[aError.errno];
1575       return this._error(
1576         ERROR_TO_GENERAL_ERROR_CLASS[error] || ERROR_UNKNOWN,
1577         aError
1578       );
1579     } else if (
1580       aError.message &&
1581       (aError.message === "INVALID_PARAMETER" ||
1582         aError.message === "NO_ACCOUNT" ||
1583         aError.message === "UNVERIFIED_ACCOUNT" ||
1584         aError.message === "AUTH_ERROR")
1585     ) {
1586       return aError;
1587     }
1588     return this._error(ERROR_UNKNOWN, aError);
1589   },
1591   _error(aError, aDetails) {
1592     log.error("FxA rejecting with error ${aError}, details: ${aDetails}", {
1593       aError,
1594       aDetails,
1595     });
1596     let reason = new Error(aError);
1597     if (aDetails) {
1598       reason.details = aDetails;
1599     }
1600     return reason;
1601   },
1603   // Attempt to update the auth server with whatever device details are stored
1604   // in the account data. Returns a promise that always resolves, never rejects.
1605   // If the promise resolves to a value, that value is the device id.
1606   updateDeviceRegistration() {
1607     return this.device.updateDeviceRegistration();
1608   },
1610   /**
1611    * Delete all the persisted credentials we store for FxA. After calling
1612    * this, the user will be forced to re-authenticate to continue.
1613    *
1614    * @return Promise resolves when the user data has been persisted
1615    */
1616   dropCredentials(state) {
1617     // Delete all fields except those required for the user to
1618     // reauthenticate.
1619     let updateData = {};
1620     let clearField = field => {
1621       if (!FXA_PWDMGR_REAUTH_ALLOWLIST.has(field)) {
1622         updateData[field] = null;
1623       }
1624     };
1625     FXA_PWDMGR_PLAINTEXT_FIELDS.forEach(clearField);
1626     FXA_PWDMGR_SECURE_FIELDS.forEach(clearField);
1628     return state.updateUserAccountData(updateData);
1629   },
1631   async checkAccountStatus(state) {
1632     log.info("checking account status...");
1633     let data = await state.getUserAccountData(["uid", "sessionToken"]);
1634     if (!data) {
1635       log.info("account status: no user");
1636       return false;
1637     }
1638     // If we have a session token, then check if that remains valid - if this
1639     // works we know the account must also be OK.
1640     if (data.sessionToken) {
1641       if (await this.fxAccountsClient.sessionStatus(data.sessionToken)) {
1642         log.info("account status: ok");
1643         return true;
1644       }
1645     }
1646     let exists = await this.fxAccountsClient.accountStatus(data.uid);
1647     if (!exists) {
1648       // Delete all local account data. Since the account no longer
1649       // exists, we can skip the remote calls.
1650       log.info("account status: deleted");
1651       await this._handleAccountDestroyed(data.uid);
1652     } else {
1653       // Note that we may already have been in a "needs reauth" state (ie, if
1654       // this function was called when we already had no session token), but
1655       // that's OK - re-notifying etc should cause no harm.
1656       log.info("account status: needs reauthentication");
1657       await this.dropCredentials(this.currentAccountState);
1658       // Notify the account state has changed so the UI updates.
1659       await this.notifyObservers(ON_ACCOUNT_STATE_CHANGE_NOTIFICATION);
1660     }
1661     return false;
1662   },
1664   async _handleTokenError(err) {
1665     if (!err || err.code != 401 || err.errno != ERRNO_INVALID_AUTH_TOKEN) {
1666       throw err;
1667     }
1668     log.warn("handling invalid token error", err);
1669     // Note that we don't use `withCurrentAccountState` here as that will cause
1670     // an error to be thrown if we sign out due to the account not existing.
1671     let state = this.currentAccountState;
1672     let ok = await this.checkAccountStatus(state);
1673     if (ok) {
1674       log.warn("invalid token error, but account state appears ok?");
1675     }
1676     // always re-throw the error.
1677     throw err;
1678   },
1681 let fxAccountsSingleton = null;
1683 export function getFxAccountsSingleton() {
1684   if (fxAccountsSingleton) {
1685     return fxAccountsSingleton;
1686   }
1688   fxAccountsSingleton = new FxAccounts();
1690   // XXX Bug 947061 - We need a strategy for resuming email verification after
1691   // browser restart
1692   fxAccountsSingleton._internal.loadAndPoll();
1694   return fxAccountsSingleton;
1697 // `AccountState` is exported for tests.