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 158390 dcfc0e0e663d47527815de8bbd24bd7a6d3667e6
parent 158389 afc8a56964686162e304b15e74efa501015a5610
child 158391 14d1050e721b15be403c76676b57cafaebc4819d
push id3745
push userryanvm@gmail.com
push dateMon, 02 Dec 2013 22:12:15 +0000
treeherderfx-team@02bb0a9faed6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmarkh
bugs935232
milestone28.0a1
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']