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