tor-browser

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

FxAccountsClient.sys.mjs (26209B)


      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/. */
      4 
      5 import { CommonUtils } from "resource://services-common/utils.sys.mjs";
      6 
      7 import { HawkClient } from "resource://services-common/hawkclient.sys.mjs";
      8 import { deriveHawkCredentials } from "resource://services-common/hawkrequest.sys.mjs";
      9 import { CryptoUtils } from "moz-src:///services/crypto/modules/utils.sys.mjs";
     10 
     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";
     20 
     21 import { Credentials } from "resource://gre/modules/Credentials.sys.mjs";
     22 
     23 const HOST_PREF = "identity.fxaccounts.auth.uri";
     24 
     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;
     29 
     30 export var FxAccountsClient = function (
     31  host = Services.prefs.getStringPref(HOST_PREF)
     32 ) {
     33  this.host = host;
     34 
     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";
     39 
     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;
     43 };
     44 
     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  },
     58 
     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  },
     68 
     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" : "";
    104 
    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);
    111 
    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  },
    145 
    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  },
    174 
    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  },
    206 
    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  },
    230 
    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  },
    250 
    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  },
    322 
    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  },
    356 
    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  },
    376 
    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    }
    393 
    394    return this._request(path, "GET", credentials);
    395  },
    396 
    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  },
    411 
    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);
    436 
    437    const resp = await this._request("/account/keys", "GET", creds);
    438    if (!resp.bundle) {
    439      throw new Error("failed to retrieve keys");
    440    }
    441 
    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    }
    455 
    456    let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64));
    457 
    458    return {
    459      kA: keyAWrapB.slice(0, 32),
    460      wrapKB: keyAWrapB.slice(32),
    461    };
    462  },
    463 
    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  },
    489 
    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  },
    517 
    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  },
    535 
    536  /**
    537   * Register a new device
    538   *
    539   * @function 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";
    567 
    568    let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
    569    let body = { name, type };
    570 
    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;
    579 
    580    return this._request(path, "POST", creds, body);
    581  },
    582 
    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   * @function 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  },
    626 
    627  /**
    628   * Retrieves pending commands for our device.
    629   *
    630   * @function 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  },
    651 
    652  /**
    653   * Invokes a command on another device.
    654   *
    655   * @function 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  },
    680 
    681  /**
    682   * Update the session or name for an existing device
    683   *
    684   * @function 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";
    710 
    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;
    721 
    722    return this._request(path, "POST", creds, body);
    723  },
    724 
    725  /**
    726   * Get a list of currently registered devices that have been accessed
    727   * in the last `DEVICES_FILTER_DAYS` days
    728   *
    729   * @function 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  },
    751 
    752  _clearBackoff() {
    753    this.backoffError = null;
    754  },
    755 
    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  },
    816 
    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  },
    826 };
    827 
    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;
    839 }