Bug 1845134 - Part 4: Update existing ui-icons to use the latest source from acorn...
[gecko.git] / services / fxaccounts / FxAccountsClient.sys.mjs
bloba62da40fd8dc1d733c81241f5684c628df6144da
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 { CommonUtils } from "resource://services-common/utils.sys.mjs";
7 import { HawkClient } from "resource://services-common/hawkclient.sys.mjs";
8 import { deriveHawkCredentials } from "resource://services-common/hawkrequest.sys.mjs";
9 import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs";
11 import {
12   ERRNO_ACCOUNT_DOES_NOT_EXIST,
13   ERRNO_INCORRECT_EMAIL_CASE,
14   ERRNO_INCORRECT_PASSWORD,
15   ERRNO_INVALID_AUTH_NONCE,
16   ERRNO_INVALID_AUTH_TIMESTAMP,
17   ERRNO_INVALID_AUTH_TOKEN,
18   log,
19 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
21 import { Credentials } from "resource://gre/modules/Credentials.sys.mjs";
23 const HOST_PREF = "identity.fxaccounts.auth.uri";
25 const SIGNIN = "/account/login";
26 const SIGNUP = "/account/create";
27 // Devices older than this many days will not appear in the devices list
28 const DEVICES_FILTER_DAYS = 21;
30 export var FxAccountsClient = function (
31   host = Services.prefs.getStringPref(HOST_PREF)
32 ) {
33   this.host = host;
35   // The FxA auth server expects requests to certain endpoints to be authorized
36   // using Hawk.
37   this.hawk = new HawkClient(host);
38   this.hawk.observerPrefix = "FxA:hawk";
40   // Manage server backoff state. C.f.
41   // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol
42   this.backoffError = null;
45 FxAccountsClient.prototype = {
46   /**
47    * Return client clock offset, in milliseconds, as determined by hawk client.
48    * Provided because callers should not have to know about hawk
49    * implementation.
50    *
51    * The offset is the number of milliseconds that must be added to the client
52    * clock to make it equal to the server clock.  For example, if the client is
53    * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
54    */
55   get localtimeOffsetMsec() {
56     return this.hawk.localtimeOffsetMsec;
57   },
59   /*
60    * Return current time in milliseconds
61    *
62    * Not used by this module, but made available to the FxAccounts.sys.mjs
63    * that uses this client.
64    */
65   now() {
66     return this.hawk.now();
67   },
69   /**
70    * Common code from signIn and signUp.
71    *
72    * @param path
73    *        Request URL path. Can be /account/create or /account/login
74    * @param email
75    *        The email address for the account (utf8)
76    * @param password
77    *        The user's password
78    * @param [getKeys=false]
79    *        If set to true the keyFetchToken will be retrieved
80    * @param [retryOK=true]
81    *        If capitalization of the email is wrong and retryOK is set to true,
82    *        we will retry with the suggested capitalization from the server
83    * @return Promise
84    *        Returns a promise that resolves to an object:
85    *        {
86    *          authAt: authentication time for the session (seconds since epoch)
87    *          email: the primary email for this account
88    *          keyFetchToken: a key fetch token (hex)
89    *          sessionToken: a session token (hex)
90    *          uid: the user's unique ID (hex)
91    *          unwrapBKey: used to unwrap kB, derived locally from the
92    *                      password (not revealed to the FxA server)
93    *          verified (optional): flag indicating verification status of the
94    *                               email
95    *        }
96    */
97   _createSession(path, email, password, getKeys = false, retryOK = true) {
98     return Credentials.setup(email, password).then(creds => {
99       let data = {
100         authPW: CommonUtils.bytesAsHex(creds.authPW),
101         email,
102       };
103       let keys = getKeys ? "?keys=true" : "";
105       return this._request(path + keys, "POST", null, data).then(
106         // Include the canonical capitalization of the email in the response so
107         // the caller can set its signed-in user state accordingly.
108         result => {
109           result.email = data.email;
110           result.unwrapBKey = CommonUtils.bytesAsHex(creds.unwrapBKey);
112           return result;
113         },
114         error => {
115           log.debug("Session creation failed", error);
116           // If the user entered an email with different capitalization from
117           // what's stored in the database (e.g., Greta.Garbo@gmail.COM as
118           // opposed to greta.garbo@gmail.com), the server will respond with a
119           // errno 120 (code 400) and the expected capitalization of the email.
120           // We retry with this email exactly once.  If successful, we use the
121           // server's version of the email as the signed-in-user's email. This
122           // is necessary because the email also serves as salt; so we must be
123           // in agreement with the server on capitalization.
124           //
125           // API reference:
126           // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md
127           if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) {
128             if (!error.email) {
129               log.error("Server returned errno 120 but did not provide email");
130               throw error;
131             }
132             return this._createSession(
133               path,
134               error.email,
135               password,
136               getKeys,
137               false
138             );
139           }
140           throw error;
141         }
142       );
143     });
144   },
146   /**
147    * Create a new Firefox Account and authenticate
148    *
149    * @param email
150    *        The email address for the account (utf8)
151    * @param password
152    *        The user's password
153    * @param [getKeys=false]
154    *        If set to true the keyFetchToken will be retrieved
155    * @return Promise
156    *        Returns a promise that resolves to an object:
157    *        {
158    *          uid: the user's unique ID (hex)
159    *          sessionToken: a session token (hex)
160    *          keyFetchToken: a key fetch token (hex),
161    *          unwrapBKey: used to unwrap kB, derived locally from the
162    *                      password (not revealed to the FxA server)
163    *        }
164    */
165   signUp(email, password, getKeys = false) {
166     return this._createSession(
167       SIGNUP,
168       email,
169       password,
170       getKeys,
171       false /* no retry */
172     );
173   },
175   /**
176    * Authenticate and create a new session with the Firefox Account API server
177    *
178    * @param email
179    *        The email address for the account (utf8)
180    * @param password
181    *        The user's password
182    * @param [getKeys=false]
183    *        If set to true the keyFetchToken will be retrieved
184    * @return Promise
185    *        Returns a promise that resolves to an object:
186    *        {
187    *          authAt: authentication time for the session (seconds since epoch)
188    *          email: the primary email for this account
189    *          keyFetchToken: a key fetch token (hex)
190    *          sessionToken: a session token (hex)
191    *          uid: the user's unique ID (hex)
192    *          unwrapBKey: used to unwrap kB, derived locally from the
193    *                      password (not revealed to the FxA server)
194    *          verified: flag indicating verification status of the email
195    *        }
196    */
197   signIn: function signIn(email, password, getKeys = false) {
198     return this._createSession(
199       SIGNIN,
200       email,
201       password,
202       getKeys,
203       true /* retry */
204     );
205   },
207   /**
208    * Check the status of a session given a session token
209    *
210    * @param sessionTokenHex
211    *        The session token encoded in hex
212    * @return Promise
213    *        Resolves with a boolean indicating if the session is still valid
214    */
215   async sessionStatus(sessionTokenHex) {
216     const credentials = await deriveHawkCredentials(
217       sessionTokenHex,
218       "sessionToken"
219     );
220     return this._request("/session/status", "GET", credentials).then(
221       () => Promise.resolve(true),
222       error => {
223         if (isInvalidTokenError(error)) {
224           return Promise.resolve(false);
225         }
226         throw error;
227       }
228     );
229   },
231   /**
232    * List all the clients connected to the authenticated user's account,
233    * including devices, OAuth clients, and web sessions.
234    *
235    * @param sessionTokenHex
236    *        The session token encoded in hex
237    * @return Promise
238    */
239   async attachedClients(sessionTokenHex) {
240     const credentials = await deriveHawkCredentials(
241       sessionTokenHex,
242       "sessionToken"
243     );
244     return this._requestWithHeaders(
245       "/account/attached_clients",
246       "GET",
247       credentials
248     );
249   },
251   /**
252    * Retrieves an OAuth authorization code.
253    *
254    * @param String sessionTokenHex
255    *        The session token encoded in hex
256    * @param {Object} options
257    * @param options.client_id
258    * @param options.state
259    * @param options.scope
260    * @param options.access_type
261    * @param options.code_challenge_method
262    * @param options.code_challenge
263    * @param [options.keys_jwe]
264    * @returns {Promise<Object>} Object containing `code` and `state`.
265    */
266   async oauthAuthorize(sessionTokenHex, options) {
267     const credentials = await deriveHawkCredentials(
268       sessionTokenHex,
269       "sessionToken"
270     );
271     const body = {
272       client_id: options.client_id,
273       response_type: "code",
274       state: options.state,
275       scope: options.scope,
276       access_type: options.access_type,
277       code_challenge: options.code_challenge,
278       code_challenge_method: options.code_challenge_method,
279     };
280     if (options.keys_jwe) {
281       body.keys_jwe = options.keys_jwe;
282     }
283     return this._request("/oauth/authorization", "POST", credentials, body);
284   },
285   /**
286    * Exchanges an OAuth authorization code with a refresh token, access tokens and an optional JWE representing scoped keys
287    *  Takes in the sessionToken to tie the device record associated with the session, with the device record associated with the refreshToken
288    *
289    * @param string sessionTokenHex: The session token encoded in hex
290    * @param String code: OAuth authorization code
291    * @param String verifier: OAuth PKCE verifier
292    * @param String clientId: OAuth client ID
293    *
294    * @returns { Object } object containing `refresh_token`, `access_token` and `keys_jwe`
295    **/
296   async oauthToken(sessionTokenHex, code, verifier, clientId) {
297     const credentials = await deriveHawkCredentials(
298       sessionTokenHex,
299       "sessionToken"
300     );
301     const body = {
302       grant_type: "authorization_code",
303       code,
304       client_id: clientId,
305       code_verifier: verifier,
306     };
307     return this._request("/oauth/token", "POST", credentials, body);
308   },
309   /**
310    * Destroy an OAuth access token or refresh token.
311    *
312    * @param String clientId
313    * @param String token The token to be revoked.
314    */
315   async oauthDestroy(clientId, token) {
316     const body = {
317       client_id: clientId,
318       token,
319     };
320     return this._request("/oauth/destroy", "POST", null, body);
321   },
323   /**
324    * Query for the information required to derive
325    * scoped encryption keys requested by the specified OAuth client.
326    *
327    * @param sessionTokenHex
328    *        The session token encoded in hex
329    * @param clientId
330    * @param scope
331    *        Space separated list of scopes
332    * @return Promise
333    */
334   async getScopedKeyData(sessionTokenHex, clientId, scope) {
335     if (!clientId) {
336       throw new Error("Missing 'clientId' parameter");
337     }
338     if (!scope) {
339       throw new Error("Missing 'scope' parameter");
340     }
341     const params = {
342       client_id: clientId,
343       scope,
344     };
345     const credentials = await deriveHawkCredentials(
346       sessionTokenHex,
347       "sessionToken"
348     );
349     return this._request(
350       "/account/scoped-key-data",
351       "POST",
352       credentials,
353       params
354     );
355   },
357   /**
358    * Destroy the current session with the Firefox Account API server and its
359    * associated device.
360    *
361    * @param sessionTokenHex
362    *        The session token encoded in hex
363    * @return Promise
364    */
365   async signOut(sessionTokenHex, options = {}) {
366     const credentials = await deriveHawkCredentials(
367       sessionTokenHex,
368       "sessionToken"
369     );
370     let path = "/session/destroy";
371     if (options.service) {
372       path += "?service=" + encodeURIComponent(options.service);
373     }
374     return this._request(path, "POST", credentials);
375   },
377   /**
378    * Check the verification status of the user's FxA email address
379    *
380    * @param sessionTokenHex
381    *        The current session token encoded in hex
382    * @return Promise
383    */
384   async recoveryEmailStatus(sessionTokenHex, options = {}) {
385     const credentials = await deriveHawkCredentials(
386       sessionTokenHex,
387       "sessionToken"
388     );
389     let path = "/recovery_email/status";
390     if (options.reason) {
391       path += "?reason=" + encodeURIComponent(options.reason);
392     }
394     return this._request(path, "GET", credentials);
395   },
397   /**
398    * Resend the verification email for the user
399    *
400    * @param sessionTokenHex
401    *        The current token encoded in hex
402    * @return Promise
403    */
404   async resendVerificationEmail(sessionTokenHex) {
405     const credentials = await deriveHawkCredentials(
406       sessionTokenHex,
407       "sessionToken"
408     );
409     return this._request("/recovery_email/resend_code", "POST", credentials);
410   },
412   /**
413    * Retrieve encryption keys
414    *
415    * @param keyFetchTokenHex
416    *        A one-time use key fetch token encoded in hex
417    * @return Promise
418    *        Returns a promise that resolves to an object:
419    *        {
420    *          kA: an encryption key for recevorable data (bytes)
421    *          wrapKB: an encryption key that requires knowledge of the
422    *                  user's password (bytes)
423    *        }
424    */
425   async accountKeys(keyFetchTokenHex) {
426     let creds = await deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken");
427     let keyRequestKey = creds.extra.slice(0, 32);
428     let morecreds = await CryptoUtils.hkdfLegacy(
429       keyRequestKey,
430       undefined,
431       Credentials.keyWord("account/keys"),
432       3 * 32
433     );
434     let respHMACKey = morecreds.slice(0, 32);
435     let respXORKey = morecreds.slice(32, 96);
437     const resp = await this._request("/account/keys", "GET", creds);
438     if (!resp.bundle) {
439       throw new Error("failed to retrieve keys");
440     }
442     let bundle = CommonUtils.hexToBytes(resp.bundle);
443     let mac = bundle.slice(-32);
444     let key = CommonUtils.byteStringToArrayBuffer(respHMACKey);
445     // CryptoUtils.hmac takes ArrayBuffers as inputs for the key and data and
446     // returns an ArrayBuffer.
447     let bundleMAC = await CryptoUtils.hmac(
448       "SHA-256",
449       key,
450       CommonUtils.byteStringToArrayBuffer(bundle.slice(0, -32))
451     );
452     if (mac !== CommonUtils.arrayBufferToByteString(bundleMAC)) {
453       throw new Error("error unbundling encryption keys");
454     }
456     let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64));
458     return {
459       kA: keyAWrapB.slice(0, 32),
460       wrapKB: keyAWrapB.slice(32),
461     };
462   },
464   /**
465    * Obtain an OAuth access token by authenticating using a session token.
466    *
467    * @param {String} sessionTokenHex
468    *        The session token encoded in hex
469    * @param {String} clientId
470    * @param {String} scope
471    *        List of space-separated scopes.
472    * @param {Number} ttl
473    *        Token time to live.
474    * @return {Promise<Object>} Object containing an `access_token`.
475    */
476   async accessTokenWithSessionToken(sessionTokenHex, clientId, scope, ttl) {
477     const credentials = await deriveHawkCredentials(
478       sessionTokenHex,
479       "sessionToken"
480     );
481     const body = {
482       client_id: clientId,
483       grant_type: "fxa-credentials",
484       scope,
485       ttl,
486     };
487     return this._request("/oauth/token", "POST", credentials, body);
488   },
490   /**
491    * Determine if an account exists
492    *
493    * @param email
494    *        The email address to check
495    * @return Promise
496    *        The promise resolves to true if the account exists, or false
497    *        if it doesn't. The promise is rejected on other errors.
498    */
499   accountExists(email) {
500     return this.signIn(email, "").then(
501       () => {
502         throw new Error("How did I sign in with an empty password?");
503       },
504       expectedError => {
505         switch (expectedError.errno) {
506           case ERRNO_ACCOUNT_DOES_NOT_EXIST:
507             return false;
508           case ERRNO_INCORRECT_PASSWORD:
509             return true;
510           default:
511             // not so expected, any more ...
512             throw expectedError;
513         }
514       }
515     );
516   },
518   /**
519    * Given the uid of an existing account (not an arbitrary email), ask
520    * the server if it still exists via /account/status.
521    *
522    * Used for differentiating between password change and account deletion.
523    */
524   accountStatus(uid) {
525     return this._request("/account/status?uid=" + uid, "GET").then(
526       result => {
527         return result.exists;
528       },
529       error => {
530         log.error("accountStatus failed", error);
531         return Promise.reject(error);
532       }
533     );
534   },
536   /**
537    * Register a new device
538    *
539    * @method registerDevice
540    * @param  sessionTokenHex
541    *         Session token obtained from signIn
542    * @param  name
543    *         Device name
544    * @param  type
545    *         Device type (mobile|desktop)
546    * @param  [options]
547    *         Extra device options
548    * @param  [options.availableCommands]
549    *         Available commands for this device
550    * @param  [options.pushCallback]
551    *         `pushCallback` push endpoint callback
552    * @param  [options.pushPublicKey]
553    *         `pushPublicKey` push public key (URLSafe Base64 string)
554    * @param  [options.pushAuthKey]
555    *         `pushAuthKey` push auth secret (URLSafe Base64 string)
556    * @return Promise
557    *         Resolves to an object:
558    *         {
559    *           id: Device identifier
560    *           createdAt: Creation time (milliseconds since epoch)
561    *           name: Name of device
562    *           type: Type of device (mobile|desktop)
563    *         }
564    */
565   async registerDevice(sessionTokenHex, name, type, options = {}) {
566     let path = "/account/device";
568     let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
569     let body = { name, type };
571     if (options.pushCallback) {
572       body.pushCallback = options.pushCallback;
573     }
574     if (options.pushPublicKey && options.pushAuthKey) {
575       body.pushPublicKey = options.pushPublicKey;
576       body.pushAuthKey = options.pushAuthKey;
577     }
578     body.availableCommands = options.availableCommands;
580     return this._request(path, "POST", creds, body);
581   },
583   /**
584    * Sends a message to other devices. Must conform with the push payload schema:
585    * https://github.com/mozilla/fxa-auth-server/blob/master/docs/pushpayloads.schema.json
586    *
587    * @method notifyDevice
588    * @param  sessionTokenHex
589    *         Session token obtained from signIn
590    * @param  deviceIds
591    *         Devices to send the message to. If null, will be sent to all devices.
592    * @param  excludedIds
593    *         Devices to exclude when sending to all devices (deviceIds must be null).
594    * @param  payload
595    *         Data to send with the message
596    * @return Promise
597    *         Resolves to an empty object:
598    *         {}
599    */
600   async notifyDevices(
601     sessionTokenHex,
602     deviceIds,
603     excludedIds,
604     payload,
605     TTL = 0
606   ) {
607     const credentials = await deriveHawkCredentials(
608       sessionTokenHex,
609       "sessionToken"
610     );
611     if (deviceIds && excludedIds) {
612       throw new Error(
613         "You cannot specify excluded devices if deviceIds is set."
614       );
615     }
616     const body = {
617       to: deviceIds || "all",
618       payload,
619       TTL,
620     };
621     if (excludedIds) {
622       body.excluded = excludedIds;
623     }
624     return this._request("/account/devices/notify", "POST", credentials, body);
625   },
627   /**
628    * Retrieves pending commands for our device.
629    *
630    * @method getCommands
631    * @param  sessionTokenHex - Session token obtained from signIn
632    * @param  [index] - If specified, only messages received after the one who
633    *                   had that index will be retrieved.
634    * @param  [limit] - Maximum number of messages to retrieve.
635    */
636   async getCommands(sessionTokenHex, { index, limit }) {
637     const credentials = await deriveHawkCredentials(
638       sessionTokenHex,
639       "sessionToken"
640     );
641     const params = new URLSearchParams();
642     if (index != undefined) {
643       params.set("index", index);
644     }
645     if (limit != undefined) {
646       params.set("limit", limit);
647     }
648     const path = `/account/device/commands?${params.toString()}`;
649     return this._request(path, "GET", credentials);
650   },
652   /**
653    * Invokes a command on another device.
654    *
655    * @method invokeCommand
656    * @param  sessionTokenHex - Session token obtained from signIn
657    * @param  command - Name of the command to invoke
658    * @param  target - Recipient device ID.
659    * @param  payload
660    * @return Promise
661    *         Resolves to the request's response, (which should be an empty object)
662    */
663   async invokeCommand(sessionTokenHex, command, target, payload) {
664     const credentials = await deriveHawkCredentials(
665       sessionTokenHex,
666       "sessionToken"
667     );
668     const body = {
669       command,
670       target,
671       payload,
672     };
673     return this._request(
674       "/account/devices/invoke_command",
675       "POST",
676       credentials,
677       body
678     );
679   },
681   /**
682    * Update the session or name for an existing device
683    *
684    * @method updateDevice
685    * @param  sessionTokenHex
686    *         Session token obtained from signIn
687    * @param  id
688    *         Device identifier
689    * @param  name
690    *         Device name
691    * @param  [options]
692    *         Extra device options
693    * @param  [options.availableCommands]
694    *         Available commands for this device
695    * @param  [options.pushCallback]
696    *         `pushCallback` push endpoint callback
697    * @param  [options.pushPublicKey]
698    *         `pushPublicKey` push public key (URLSafe Base64 string)
699    * @param  [options.pushAuthKey]
700    *         `pushAuthKey` push auth secret (URLSafe Base64 string)
701    * @return Promise
702    *         Resolves to an object:
703    *         {
704    *           id: Device identifier
705    *           name: Device name
706    *         }
707    */
708   async updateDevice(sessionTokenHex, id, name, options = {}) {
709     let path = "/account/device";
711     let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
712     let body = { id, name };
713     if (options.pushCallback) {
714       body.pushCallback = options.pushCallback;
715     }
716     if (options.pushPublicKey && options.pushAuthKey) {
717       body.pushPublicKey = options.pushPublicKey;
718       body.pushAuthKey = options.pushAuthKey;
719     }
720     body.availableCommands = options.availableCommands;
722     return this._request(path, "POST", creds, body);
723   },
725   /**
726    * Get a list of currently registered devices that have been accessed
727    * in the last `DEVICES_FILTER_DAYS` days
728    *
729    * @method getDeviceList
730    * @param  sessionTokenHex
731    *         Session token obtained from signIn
732    * @return Promise
733    *         Resolves to an array of objects:
734    *         [
735    *           {
736    *             id: Device id
737    *             isCurrentDevice: Boolean indicating whether the item
738    *                              represents the current device
739    *             name: Device name
740    *             type: Device type (mobile|desktop)
741    *           },
742    *           ...
743    *         ]
744    */
745   async getDeviceList(sessionTokenHex) {
746     let timestamp = Date.now() - 1000 * 60 * 60 * 24 * DEVICES_FILTER_DAYS;
747     let path = `/account/devices?filterIdleDevicesTimestamp=${timestamp}`;
748     let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
749     return this._request(path, "GET", creds, {});
750   },
752   _clearBackoff() {
753     this.backoffError = null;
754   },
756   /**
757    * A general method for sending raw API calls to the FxA auth server.
758    * All request bodies and responses are JSON.
759    *
760    * @param path
761    *        API endpoint path
762    * @param method
763    *        The HTTP request method
764    * @param credentials
765    *        Hawk credentials
766    * @param jsonPayload
767    *        A JSON payload
768    * @return Promise
769    *        Returns a promise that resolves to the JSON response of the API call,
770    *        or is rejected with an error. Error responses have the following properties:
771    *        {
772    *          "code": 400, // matches the HTTP status code
773    *          "errno": 107, // stable application-level error number
774    *          "error": "Bad Request", // string description of the error type
775    *          "message": "the value of salt is not allowed to be undefined",
776    *          "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error
777    *        }
778    */
779   async _requestWithHeaders(path, method, credentials, jsonPayload) {
780     // We were asked to back off.
781     if (this.backoffError) {
782       log.debug("Received new request during backoff, re-rejecting.");
783       throw this.backoffError;
784     }
785     let response;
786     try {
787       response = await this.hawk.request(
788         path,
789         method,
790         credentials,
791         jsonPayload
792       );
793     } catch (error) {
794       log.error(`error ${method}ing ${path}`, error);
795       if (error.retryAfter) {
796         log.debug("Received backoff response; caching error as flag.");
797         this.backoffError = error;
798         // Schedule clearing of cached-error-as-flag.
799         CommonUtils.namedTimer(
800           this._clearBackoff,
801           error.retryAfter * 1000,
802           this,
803           "fxaBackoffTimer"
804         );
805       }
806       throw error;
807     }
808     try {
809       return { body: JSON.parse(response.body), headers: response.headers };
810     } catch (error) {
811       log.error("json parse error on response: " + response.body);
812       // eslint-disable-next-line no-throw-literal
813       throw { error };
814     }
815   },
817   async _request(path, method, credentials, jsonPayload) {
818     const response = await this._requestWithHeaders(
819       path,
820       method,
821       credentials,
822       jsonPayload
823     );
824     return response.body;
825   },
828 function isInvalidTokenError(error) {
829   if (error.code != 401) {
830     return false;
831   }
832   switch (error.errno) {
833     case ERRNO_INVALID_AUTH_TOKEN:
834     case ERRNO_INVALID_AUTH_TIMESTAMP:
835     case ERRNO_INVALID_AUTH_NONCE:
836       return true;
837   }
838   return false;