Bug 935232 - Implement a client for the Firefox Accounts auth server. r=markh
authorZachary Carter <zcarter@mozilla.com>
Mon, 02 Dec 2013 13:56:24 -0800
changeset 174067 dcfc0e0e663d47527815de8bbd24bd7a6d3667e6
parent 174066 afc8a56964686162e304b15e74efa501015a5610
child 174068 14d1050e721b15be403c76676b57cafaebc4819d
push id445
push userffxbld
push dateMon, 10 Mar 2014 22:05:19 +0000
treeherdermozilla-release@dc38b741b04e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmarkh
bugs935232
milestone28.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 935232 - Implement a client for the Firefox Accounts auth server. r=markh
b2g/app/b2g.js
browser/app/profile/firefox.js
services/common/tests/unit/head_helpers.js
services/common/utils.js
services/crypto/modules/utils.js
services/crypto/tests/unit/test_utils_hkdfExpand.js
services/fxaccounts/FxAccountsClient.jsm
services/fxaccounts/moz.build
services/fxaccounts/tests/moz.build
services/fxaccounts/tests/xpcshell/head.js
services/fxaccounts/tests/xpcshell/test_client.js
services/fxaccounts/tests/xpcshell/xpcshell.ini
services/moz.build
--- a/b2g/app/b2g.js
+++ b/b2g/app/b2g.js
@@ -842,8 +842,11 @@ pref("ril.cellbroadcast.disabled", false
 pref("b2g.neterror.url", "app://system.gaiamobile.org/net_error.html");
 
 // Enable Web Speech synthesis API
 pref("media.webspeech.synth.enabled", true);
 
 // Downloads API
 pref("dom.mozDownloads.enabled", true);
 pref("dom.downloads.max_retention_days", 7);
+
+// The URL of the Firefox Accounts auth server backend
+pref("identity.fxaccounts.auth.uri", "https://api-accounts.dev.lcip.org/v1");
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1330,8 +1330,11 @@ pref("dom.debug.propagate_gesture_events
 pref("geo.wifi.uri", "https://www.googleapis.com/geolocation/v1/geolocate?key=%GOOGLE_API_KEY%");
 
 // Necko IPC security checks only needed for app isolation for cookies/cache/etc:
 // currently irrelevant for desktop e10s
 pref("network.disable.ipc.security", true);
 
 // CustomizableUI debug logging.
 pref("browser.uiCustomization.debug", false);
+
+// The URL of the Firefox Accounts auth server backend
+pref("identity.fxaccounts.auth.uri", "https://api-accounts.dev.lcip.org/v1");
--- a/services/common/tests/unit/head_helpers.js
+++ b/services/common/tests/unit/head_helpers.js
@@ -30,16 +30,36 @@ function do_check_throws(aFunc, aResult,
     aFunc();
   } catch (e) {
     do_check_eq(e.result, aResult, aStack);
     return;
   }
   do_throw("Expected result " + aResult + ", none thrown.", aStack);
 }
 
+
+/**
+ * Test whether specified function throws exception with expected
+ * result.
+ *
+ * @param func
+ *        Function to be tested.
+ * @param message
+ *        Message of expected exception. <code>null</code> for no throws.
+ */
+function do_check_throws_message(aFunc, aResult) {
+  try {
+    aFunc();
+  } catch (e) {
+    do_check_eq(e.message, aResult);
+    return;
+  }
+  do_throw("Expected an error, none thrown.");
+}
+
 /**
  * Print some debug message to the console. All arguments will be printed,
  * separated by spaces.
  *
  * @param [arg0, arg1, arg2, ...]
  *        Any number of arguments to print out
  * @usage _("Hello World") -> prints "Hello World"
  * @usage _(1, 2, 3) -> prints "1 2 3"
--- a/services/common/utils.js
+++ b/services/common/utils.js
@@ -199,16 +199,24 @@ this.CommonUtils = {
   bytesAsHex: function bytesAsHex(bytes) {
     let hex = "";
     for (let i = 0; i < bytes.length; i++) {
       hex += ("0" + bytes[i].charCodeAt().toString(16)).slice(-2);
     }
     return hex;
   },
 
+  hexToBytes: function hexToBytes(str) {
+    let bytes = [];
+    for (let i = 0; i < str.length - 1; i += 2) {
+      bytes.push(parseInt(str.substr(i, 2), 16));
+    }
+    return String.fromCharCode.apply(String, bytes);
+  },
+
   /**
    * Base32 encode (RFC 4648) a string
    */
   encodeBase32: function encodeBase32(bytes) {
     const key = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
     let quanta = Math.floor(bytes.length / 5);
     let leftover = bytes.length % 5;
 
--- a/services/crypto/modules/utils.js
+++ b/services/crypto/modules/utils.js
@@ -6,16 +6,30 @@ const {classes: Cc, interfaces: Ci, resu
 
 this.EXPORTED_SYMBOLS = ["CryptoUtils"];
 
 Cu.import("resource://services-common/observers.js");
 Cu.import("resource://services-common/utils.js");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 this.CryptoUtils = {
+  xor: function xor(a, b) {
+    let bytes = [];
+
+    if (a.length != b.length) {
+      throw new Error("can't xor unequal length strings: "+a.length+" vs "+b.length);
+    }
+
+    for (let i = 0; i < a.length; i++) {
+      bytes[i] = a.charCodeAt(i) ^ b.charCodeAt(i);
+    }
+
+    return String.fromCharCode.apply(String, bytes);
+  },
+
   /**
    * Generate a string of random bytes.
    */
   generateRandomBytes: function generateRandomBytes(length) {
     let rng = Cc["@mozilla.org/security/random-generator;1"]
                 .createInstance(Ci.nsIRandomGenerator);
     let bytes = rng.generateRandomBytes(length);
     return CommonUtils.byteArrayToString(bytes);
@@ -105,16 +119,32 @@ this.CryptoUtils = {
   makeHMACHasher: function makeHMACHasher(type, key) {
     let hasher = Cc["@mozilla.org/security/hmac;1"]
                    .createInstance(Ci.nsICryptoHMAC);
     hasher.init(type, key);
     return hasher;
   },
 
   /**
+   * HMAC-based Key Derivation (RFC 5869).
+   */
+  hkdf: function hkdf(ikm, xts, info, len) {
+    const BLOCKSIZE = 256 / 8;
+    if (typeof xts === undefined)
+      xts = String.fromCharCode(0, 0, 0, 0,  0, 0, 0, 0,
+                                0, 0, 0, 0,  0, 0, 0, 0,
+                                0, 0, 0, 0,  0, 0, 0, 0,
+                                0, 0, 0, 0,  0, 0, 0, 0);
+    let h = CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256,
+                                       CryptoUtils.makeHMACKey(xts));
+    let prk = CryptoUtils.digestBytes(ikm, h);
+    return CryptoUtils.hkdfExpand(prk, info, len);
+  },
+
+  /**
    * HMAC-based Key Derivation Step 2 according to RFC 5869.
    */
   hkdfExpand: function hkdfExpand(prk, info, len) {
     const BLOCKSIZE = 256 / 8;
     let h = CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256,
                                        CryptoUtils.makeHMACKey(prk));
     let T = "";
     let Tn = "";
@@ -453,29 +483,29 @@ this.CryptoUtils = {
       host: uri.asciiHost.toLowerCase(), // This includes punycoding.
       port: port.toString(10),
       hash: options.hash,
       ext: options.ext,
     };
 
     let contentType = CryptoUtils.stripHeaderAttributes(options.contentType);
 
-    if (!artifacts.hash &&
-        options.hasOwnProperty("payload") &&
-        options.payload) {
+    if (!artifacts.hash && options.hasOwnProperty("payload")
+        && options.payload) {
       let hasher = Cc["@mozilla.org/security/hash;1"]
                      .createInstance(Ci.nsICryptoHash);
       hasher.init(hash_algo);
       CryptoUtils.updateUTF8("hawk.1.payload\n", hasher);
       CryptoUtils.updateUTF8(contentType+"\n", hasher);
       CryptoUtils.updateUTF8(options.payload, hasher);
       CryptoUtils.updateUTF8("\n", hasher);
       let hash = hasher.finish(false);
-      // HAWK specifies this .hash to include trailing "==" padding.
-      let hash_b64 = CommonUtils.encodeBase64URL(hash, true);
+      // HAWK specifies this .hash to use +/ (not _-) and include the
+      // trailing "==" padding.
+      let hash_b64 = btoa(hash);
       artifacts.hash = hash_b64;
     }
 
     let requestString = ("hawk.1.header"        + "\n" +
                          artifacts.ts.toString(10) + "\n" +
                          artifacts.nonce        + "\n" +
                          artifacts.method       + "\n" +
                          artifacts.resource     + "\n" +
--- a/services/crypto/tests/unit/test_utils_hkdfExpand.js
+++ b/services/crypto/tests/unit/test_utils_hkdfExpand.js
@@ -88,21 +88,33 @@ function extract_hex(salt, ikm) {
 }
 
 function expand_hex(prk, info, len) {
   prk = _hexToString(prk);
   info = _hexToString(info);
   return CommonUtils.bytesAsHex(CryptoUtils.hkdfExpand(prk, info, len));
 }
 
+function hkdf_hex(ikm, salt, info, len) {
+  ikm = _hexToString(ikm);
+  if (salt)
+    salt = _hexToString(salt);
+  info = _hexToString(info);
+  return CommonUtils.bytesAsHex(CryptoUtils.hkdf(ikm, salt, info, len));
+}
+
 function run_test() {
   _("Verifying Test Case 1");
   do_check_eq(extract_hex(tc1.salt, tc1.IKM), tc1.PRK);
   do_check_eq(expand_hex(tc1.PRK, tc1.info, tc1.L), tc1.OKM);
+  do_check_eq(hkdf_hex(tc1.IKM, tc1.salt, tc1.info, tc1.L), tc1.OKM);
 
   _("Verifying Test Case 2");
   do_check_eq(extract_hex(tc2.salt, tc2.IKM), tc2.PRK);
   do_check_eq(expand_hex(tc2.PRK, tc2.info, tc2.L), tc2.OKM);
+  do_check_eq(hkdf_hex(tc2.IKM, tc2.salt, tc2.info, tc2.L), tc2.OKM);
 
   _("Verifying Test Case 3");
   do_check_eq(extract_hex(tc3.salt, tc3.IKM), tc3.PRK);
   do_check_eq(expand_hex(tc3.PRK, tc3.info, tc3.L), tc3.OKM);
+  do_check_eq(hkdf_hex(tc3.IKM, tc3.salt, tc3.info, tc3.L), tc3.OKM);
+  do_check_eq(hkdf_hex(tc3.IKM, undefined, tc3.info, tc3.L), tc3.OKM);
 }
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/FxAccountsClient.jsm
@@ -0,0 +1,330 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+this.EXPORTED_SYMBOLS = ["FxAccountsClient"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://services-crypto/utils.js");
+
+// Default can be changed by the preference 'identity.fxaccounts.auth.uri'
+let _host = "https://api-accounts.dev.lcip.org/v1";
+try {
+  _host = Services.prefs.getCharPref("identity.fxaccounts.auth.uri");
+} catch(keepDefault) {}
+
+const HOST = _host;
+const PREFIX_NAME = "identity.mozilla.com/picl/v1/";
+
+const XMLHttpRequest =
+  Components.Constructor("@mozilla.org/xmlextras/xmlhttprequest;1");
+
+
+function stringToHex(str) {
+  let encoder = new TextEncoder("utf-8");
+  let bytes = encoder.encode(str);
+  return bytesToHex(bytes);
+}
+
+// XXX Sadly, CommonUtils.bytesAsHex doesn't handle typed arrays.
+function bytesToHex(bytes) {
+  let hex = [];
+  for (let i = 0; i < bytes.length; i++) {
+    hex.push((bytes[i] >>> 4).toString(16));
+    hex.push((bytes[i] & 0xF).toString(16));
+  }
+  return hex.join("");
+}
+
+this.FxAccountsClient = function(host = HOST) {
+  this.host = host;
+};
+
+this.FxAccountsClient.prototype = {
+  /**
+   * Create a new Firefox Account and authenticate
+   *
+   * @param email
+   *        The email address for the account (utf8)
+   * @param password
+   *        The user's password
+   * @return Promise
+   *        Returns a promise that resolves to an object:
+   *        {
+   *          uid: the user's unique ID
+   *          sessionToken: a session token
+   *        }
+   */
+  signUp: function (email, password) {
+    let uid;
+    let hexEmail = stringToHex(email);
+    let uidPromise = this._request("/raw_password/account/create", "POST", null,
+                          {email: hexEmail, password: password});
+
+    return uidPromise.then((result) => {
+      uid = result.uid;
+      return this.signIn(email, password)
+        .then(function(result) {
+          result.uid = uid;
+          return result;
+        });
+    });
+  },
+
+  /**
+   * Authenticate and create a new session with the Firefox Account API server
+   *
+   * @param email
+   *        The email address for the account (utf8)
+   * @param password
+   *        The user's password
+   * @return Promise
+   *        Returns a promise that resolves to an object:
+   *        {
+   *          uid: the user's unique ID
+   *          sessionToken: a session token
+   *          isVerified: flag indicating verification status of the email
+   *        }
+   */
+  signIn: function signIn(email, password) {
+    let hexEmail = stringToHex(email);
+    return this._request("/raw_password/session/create", "POST", null,
+                         {email: hexEmail, password: password});
+  },
+
+  /**
+   * Destroy the current session with the Firefox Account API server
+   *
+   * @param sessionTokenHex
+   *        The session token endcoded in hex
+   * @return Promise
+   */
+  signOut: function (sessionTokenHex) {
+    return this._request("/session/destroy", "POST",
+      this._deriveHawkCredentials(sessionTokenHex, "sessionToken"));
+  },
+
+  /**
+   * Check the verification status of the user's FxA email address
+   *
+   * @param sessionTokenHex
+   *        The current session token endcoded in hex
+   * @return Promise
+   */
+  recoveryEmailStatus: function (sessionTokenHex) {
+    return this._request("/recovery_email/status", "GET",
+      this._deriveHawkCredentials(sessionTokenHex, "sessionToken"));
+  },
+
+  /**
+   * Retrieve encryption keys
+   *
+   * @param keyFetchTokenHex
+   *        A one-time use key fetch token encoded in hex
+   * @return Promise
+   *        Returns a promise that resolves to an object:
+   *        {
+   *          kA: an encryption key for recevorable data
+   *          wrapKB: an encryption key that requires knowledge of the user's password
+   *        }
+   */
+  accountKeys: function (keyFetchTokenHex) {
+    let creds = this._deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken");
+    let keyRequestKey = creds.extra.slice(0, 32);
+    let morecreds = CryptoUtils.hkdf(keyRequestKey, undefined,
+                                     PREFIX_NAME + "account/keys", 3 * 32);
+    let respHMACKey = morecreds.slice(0, 32);
+    let respXORKey = morecreds.slice(32, 96);
+
+    return this._request("/account/keys", "GET", creds).then(resp => {
+      if (!resp.bundle) {
+        throw new Error("failed to retrieve keys");
+      }
+
+      let bundle = CommonUtils.hexToBytes(resp.bundle);
+      let mac = bundle.slice(-32);
+
+      let hasher = CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256,
+        CryptoUtils.makeHMACKey(respHMACKey));
+
+      let bundleMAC = CryptoUtils.digestBytes(bundle.slice(0, -32), hasher);
+      if (mac !== bundleMAC) {
+        throw new Error("error unbundling encryption keys");
+      }
+
+      let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64));
+
+      return {
+        kA: keyAWrapB.slice(0, 32),
+        wrapKB: keyAWrapB.slice(32)
+      };
+    });
+  },
+
+  /**
+   * Sends a public key to the FxA API server and returns a signed certificate
+   *
+   * @param sessionTokenHex
+   *        The current session token endcoded in hex
+   * @param serializedPublicKey
+   *        A public key (usually generated by jwcrypto)
+   * @param lifetime
+   *        The lifetime of the certificate
+   * @return Promise
+   *        Returns a promise that resolves to the signed certificate. The certificate
+   *        can be used to generate a Persona assertion.
+   */
+  signCertificate: function (sessionTokenHex, serializedPublicKey, lifetime) {
+    let creds = this._deriveHawkCredentials(sessionTokenHex, "sessionToken");
+
+    let body = { publicKey: serializedPublicKey,
+                 duration: lifetime };
+    return Promise.resolve()
+      .then(_ => this._request("/certificate/sign", "POST", creds, body))
+      .then(resp => resp.cert,
+            err => {dump("HAWK.signCertificate error: " + err + "\n");
+                    throw err;});
+  },
+
+  /**
+   * Determine if an account exists
+   *
+   * @param email
+   *        The email address to check
+   * @return Promise
+   *        The promise resolves to true if the account exists, or false
+   *        if it doesn't. The promise is rejected on other errors.
+   */
+  accountExists: function (email) {
+    let hexEmail = stringToHex(email);
+    return this._request("/auth/start", "POST", null, { email: hexEmail })
+      .then(
+        // the account exists
+        (result) => true,
+        (err) => {
+          // the account doesn't exist
+          if (err.errno === 102) {
+            return false;
+          }
+          // propogate other request errors
+          throw err;
+        }
+      );
+  },
+
+  /**
+   * The FxA auth server expects requests to certain endpoints to be authorized using Hawk.
+   * Hawk credentials are derived using shared secrets, which depend on the context
+   * (e.g. sessionToken vs. keyFetchToken).
+   *
+   * @param tokenHex
+   *        The current session token endcoded in hex
+   * @param context
+   *        A context for the credentials
+   * @param size
+   *        The size in bytes of the expected derived buffer
+   * @return credentials
+   *        Returns an object:
+   *        {
+   *          algorithm: sha256
+   *          id: the Hawk id (from the first 32 bytes derived)
+   *          key: the Hawk key (from bytes 32 to 64)
+   *          extra: size - 64 extra bytes
+   *        }
+   */
+  _deriveHawkCredentials: function (tokenHex, context, size) {
+    let token = CommonUtils.hexToBytes(tokenHex);
+    let out = CryptoUtils.hkdf(token, undefined, PREFIX_NAME + context, size || 3 * 32);
+
+    return {
+      algorithm: "sha256",
+      key: out.slice(32, 64),
+      extra: out.slice(64),
+      id: CommonUtils.bytesAsHex(out.slice(0, 32))
+    };
+  },
+
+  /**
+   * A general method for sending raw API calls to the FxA auth server.
+   * All request bodies and responses are JSON.
+   *
+   * @param path
+   *        API endpoint path
+   * @param method
+   *        The HTTP request method
+   * @param credentials
+   *        Hawk credentials
+   * @param jsonPayload
+   *        A JSON payload
+   * @return Promise
+   *        Returns a promise that resolves to the JSON response of the API call,
+   *        or is rejected with an error. Error responses have the following properties:
+   *        {
+   *          "code": 400, // matches the HTTP status code
+   *          "errno": 107, // stable application-level error number
+   *          "error": "Bad Request", // string description of the error type
+   *          "message": "the value of salt is not allowed to be undefined",
+   *          "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error
+   *        }
+   */
+  _request: function hawkRequest(path, method, credentials, jsonPayload) {
+    let deferred = Promise.defer();
+    let xhr = new XMLHttpRequest({mozSystem: true});
+    let URI = this.host + path;
+    let payload;
+
+    xhr.mozBackgroundRequest = true;
+
+    if (jsonPayload) {
+      payload = JSON.stringify(jsonPayload);
+    }
+
+    xhr.open(method, URI);
+    xhr.channel.loadFlags = Ci.nsIChannel.LOAD_BYPASS_CACHE |
+                            Ci.nsIChannel.INHIBIT_CACHING;
+
+    // When things really blow up, reconstruct an error object that follows the general format
+    // of the server on error responses.
+    function constructError(err) {
+      return { error: err, message: xhr.statusText, code: xhr.status, errno: xhr.status };
+    }
+
+    xhr.onerror = function() {
+      deferred.reject(constructError('Request failed'));
+    };
+
+    xhr.onload = function onload() {
+      try {
+        let response = JSON.parse(xhr.responseText);
+        if (xhr.status !== 200 || response.error) {
+          // In this case, the response is an object with error information.
+          return deferred.reject(response);
+        }
+        deferred.resolve(response);
+      } catch (e) {
+        deferred.reject(constructError(e));
+      }
+    };
+
+    let uri = Services.io.newURI(URI, null, null);
+
+    if (credentials) {
+      let header = CryptoUtils.computeHAWK(uri, method, {
+                          credentials: credentials,
+                          payload: payload,
+                          contentType: "application/json"
+                        });
+      xhr.setRequestHeader("authorization", header.field);
+    }
+
+    xhr.setRequestHeader("Content-Type", "application/json");
+    xhr.send(payload);
+
+    return deferred.promise;
+  },
+};
+
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/moz.build
@@ -0,0 +1,8 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+TEST_DIRS += ['tests']
+EXTRA_JS_MODULES += ['FxAccountsClient.jsm']
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/moz.build
@@ -0,0 +1,7 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPCSHELL_TESTS_MANIFESTS += ['xpcshell/xpcshell.ini']
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/head.js
@@ -0,0 +1,18 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
+
+"use strict";
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+(function initFxAccountsTestingInfrastructure() {
+  do_get_profile();
+
+  let ns = {};
+  Cu.import("resource://testing-common/services-common/logging.js", ns);
+
+  ns.initTestLogging("Trace");
+}).call(this);
+
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_client.js
@@ -0,0 +1,231 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://services-common/utils.js");
+
+function run_test() {
+  run_next_test();
+}
+
+function deferredStop(server) {
+    let deferred = Promise.defer();
+    server.stop(deferred.resolve);
+    return deferred.promise;
+}
+
+add_test(function test_hawk_credentials() {
+  let client = new FxAccountsClient();
+
+  let sessionToken = "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf";
+  let result = client._deriveHawkCredentials(sessionToken, "session");
+
+  do_check_eq(result.id, "639503a218ffbb62983e9628be5cd64a0438d0ae81b2b9dadeb900a83470bc6b");
+  do_check_eq(CommonUtils.bytesAsHex(result.key), "3a0188943837ab228fe74e759566d0e4837cbcc7494157aac4da82025b2811b2");
+
+  run_next_test();
+});
+
+add_task(function test_authenticated_get_request() {
+
+  let message = "{\"msg\": \"Great Success!\"}";
+  let credentials = {
+    id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+    key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+    algorithm: "sha256"
+  };
+  let method = "GET";
+
+  let server = httpd_setup({"/foo": function(request, response) {
+      do_check_true(request.hasHeader("Authorization"));
+
+      response.setStatusLine(request.httpVersion, 200, "OK");
+      response.bodyOutputStream.write(message, message.length);
+    }
+  });
+
+  let client = new FxAccountsClient(server.baseURI);
+
+  let result = yield client._request("/foo", method, credentials);
+  do_check_eq("Great Success!", result.msg);
+
+  yield deferredStop(server);
+});
+
+add_task(function test_authenticated_post_request() {
+  let credentials = {
+    id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+    key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+    algorithm: "sha256"
+  };
+  let method = "POST";
+
+  let server = httpd_setup({"/foo": function(request, response) {
+      do_check_true(request.hasHeader("Authorization"));
+
+      response.setStatusLine(request.httpVersion, 200, "OK");
+      response.setHeader("Content-Type", "application/json");
+      response.bodyOutputStream.writeFrom(request.bodyInputStream, request.bodyInputStream.available());
+    }
+  });
+
+  let client = new FxAccountsClient(server.baseURI);
+
+  let result = yield client._request("/foo", method, credentials, {foo: "bar"});
+  do_check_eq("bar", result.foo);
+
+  yield deferredStop(server);
+});
+
+add_task(function test_500_error() {
+
+  let message = "<h1>Ooops!</h1>";
+  let method = "GET";
+
+  let server = httpd_setup({"/foo": function(request, response) {
+      response.setStatusLine(request.httpVersion, 500, "Internal Server Error");
+      response.bodyOutputStream.write(message, message.length);
+    }
+  });
+
+  let client = new FxAccountsClient(server.baseURI);
+
+  try {
+    yield client._request("/foo", method);
+  } catch (e) {
+    do_check_eq(500, e.code);
+    do_check_eq("Internal Server Error", e.message);
+  }
+
+  yield deferredStop(server);
+});
+
+add_task(function test_api_endpoints() {
+  let sessionMessage = JSON.stringify({sessionToken: "NotARealToken"});
+  let creationMessage = JSON.stringify({uid: "NotARealUid"});
+  let signoutMessage = JSON.stringify({});
+  let certSignMessage = JSON.stringify({cert: {bar: "baz"}});
+  let emailStatus = JSON.stringify({verified: true});
+
+  let authStarts = 0;
+
+  function writeResp(response, msg) {
+    response.bodyOutputStream.write(msg, msg.length);
+  }
+
+  let server = httpd_setup(
+    {
+      "/raw_password/account/create": function(request, response) {
+        let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+        let jsonBody = JSON.parse(body);
+        do_check_eq(jsonBody.email, "796f75406578616d706c652e636f6d");
+        do_check_eq(jsonBody.password, "biggersecret");
+
+        response.setStatusLine(request.httpVersion, 200, "OK");
+        response.bodyOutputStream.write(creationMessage, creationMessage.length);
+      },
+      "/raw_password/session/create": function(request, response) {
+        let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+        let jsonBody = JSON.parse(body);
+        if (jsonBody.password === "bigsecret") {
+          do_check_eq(jsonBody.email, "6dc3a9406578616d706c652e636f6d");
+        } else if (jsonBody.password === "biggersecret") {
+          do_check_eq(jsonBody.email, "796f75406578616d706c652e636f6d");
+        }
+
+        response.setStatusLine(request.httpVersion, 200, "OK");
+        response.bodyOutputStream.write(sessionMessage, sessionMessage.length);
+      },
+      "/recovery_email/status": function(request, response) {
+        do_check_true(request.hasHeader("Authorization"));
+        response.setStatusLine(request.httpVersion, 200, "OK");
+        response.bodyOutputStream.write(emailStatus, emailStatus.length);
+      },
+      "/session/destroy": function(request, response) {
+        do_check_true(request.hasHeader("Authorization"));
+        response.setStatusLine(request.httpVersion, 200, "OK");
+        response.bodyOutputStream.write(signoutMessage, signoutMessage.length);
+      },
+      "/certificate/sign": function(request, response) {
+        do_check_true(request.hasHeader("Authorization"));
+        let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+        let jsonBody = JSON.parse(body);
+        do_check_eq(JSON.parse(jsonBody.publicKey).foo, "bar");
+        do_check_eq(jsonBody.duration, 600);
+        response.setStatusLine(request.httpVersion, 200, "OK");
+        response.bodyOutputStream.write(certSignMessage, certSignMessage.length);
+      },
+      "/auth/start": function(request, response) {
+        if (authStarts === 0) {
+          response.setStatusLine(request.httpVersion, 200, "OK");
+          writeResp(response, JSON.stringify({}));
+        } else if (authStarts === 1) {
+          response.setStatusLine(request.httpVersion, 400, "NOT OK");
+          writeResp(response, JSON.stringify({errno: 102, error: "no such account"}));
+        } else if (authStarts === 2) {
+          response.setStatusLine(request.httpVersion, 400, "NOT OK");
+          writeResp(response, JSON.stringify({errno: 107, error: "boom"}));
+        }
+        authStarts++;
+      },
+    }
+  );
+
+  let client = new FxAccountsClient(server.baseURI);
+  let result = undefined;
+
+  result = yield client.signUp('you@example.com', 'biggersecret');
+  do_check_eq("NotARealUid", result.uid);
+
+  result = yield client.signIn('mé@example.com', 'bigsecret');
+  do_check_eq("NotARealToken", result.sessionToken);
+
+  result = yield client.signOut('NotARealToken');
+  do_check_eq(typeof result, "object");
+
+  result = yield client.recoveryEmailStatus('NotARealToken');
+  do_check_eq(result.verified, true);
+
+  result = yield client.signCertificate('NotARealToken', JSON.stringify({foo: "bar"}), 600);
+  do_check_eq("baz", result.bar);
+
+  result = yield client.accountExists('hey@example.com');
+  do_check_eq(result, true);
+  result = yield client.accountExists('hey2@example.com');
+  do_check_eq(result, false);
+  try {
+    result = yield client.accountExists('hey3@example.com');
+  } catch(e) {
+    do_check_eq(e.errno, 107);
+  }
+
+  yield deferredStop(server);
+});
+
+add_task(function test_error_response() {
+  let errorMessage = JSON.stringify({error: "Oops", code: 400, errno: 99});
+
+  let server = httpd_setup(
+    {
+      "/raw_password/session/create": function(request, response) {
+        let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+
+        response.setStatusLine(request.httpVersion, 400, "NOT OK");
+        response.bodyOutputStream.write(errorMessage, errorMessage.length);
+      },
+    }
+  );
+
+  let client = new FxAccountsClient(server.baseURI);
+
+  try {
+    let result = yield client.signIn('mé@example.com', 'bigsecret');
+  } catch(result) {
+    do_check_eq("Oops", result.error);
+    do_check_eq(400, result.code);
+    do_check_eq(99, result.errno);
+  }
+
+  yield deferredStop(server);
+});
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+head = head.js ../../../common/tests/unit/head_helpers.js ../../../common/tests/unit/head_http.js
+tail =
+
+[test_client.js]
+
--- a/services/moz.build
+++ b/services/moz.build
@@ -2,16 +2,17 @@
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 PARALLEL_DIRS += [
     'common',
     'crypto',
+    'fxaccounts',
 ]
 
 if CONFIG['MOZ_WIDGET_TOOLKIT'] != 'android':
     # MOZ_SERVICES_HEALTHREPORT and therefore MOZ_DATA_REPORTING are
     # defined on Android, but these features are implemented using Java.
     if CONFIG['MOZ_SERVICES_HEALTHREPORT']:
         PARALLEL_DIRS += ['healthreport']