Bug 963835 - FxA client handle incorrect capitalization in emails. r=rnewman
authorJed Parsons <jedp@mozilla.com>
Wed, 19 Feb 2014 08:34:42 -0800
changeset 170094 55b282861d33516cd3ae0d7ecc0dd47729679f2f
parent 170093 698f220114f996ad2d6069f6258eb006429e0baa
child 170095 aa9e7295c850b54cfce17aacdf575798bf759da5
push id270
push userpvanderbeken@mozilla.com
push dateThu, 06 Mar 2014 09:24:21 +0000
reviewersrnewman
bugs963835
milestone30.0a1
Bug 963835 - FxA client handle incorrect capitalization in emails. r=rnewman
services/fxaccounts/FxAccounts.jsm
services/fxaccounts/FxAccountsClient.jsm
services/fxaccounts/FxAccountsCommon.js
services/fxaccounts/tests/mochitest/chrome.ini
services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs
services/fxaccounts/tests/mochitest/test_invalidEmailCase.html
services/fxaccounts/tests/moz.build
services/fxaccounts/tests/xpcshell/test_client.js
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -19,16 +19,17 @@ Cu.import("resource://gre/modules/FxAcco
 Cu.import("resource://gre/modules/FxAccountsCommon.js");
 Cu.import("resource://gre/modules/FxAccountsUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto",
   "resource://gre/modules/identity/jwcrypto.jsm");
 
 // All properties exposed by the public FxAccounts API.
 let publicProperties = [
+  "getAccountsClient",
   "getAccountsSignInURI",
   "getAccountsURI",
   "getAssertion",
   "getKeys",
   "getSignedInUser",
   "loadAndPoll",
   "localtimeOffsetMsec",
   "now",
@@ -117,16 +118,20 @@ FxAccountsInternal.prototype = {
   /**
    * Return the current time in milliseconds as an integer.  Allows tests to
    * manipulate the date to simulate certificate expiration.
    */
   now: function() {
     return this.fxAccountsClient.now();
   },
 
+  getAccountsClient: function() {
+    return this.fxAccountsClient;
+  },
+
   /**
    * Return clock offset in milliseconds, as reported by the fxAccountsClient.
    * This can be overridden for testing.
    *
    * The offset is the number of milliseconds that must be added to the client
    * clock to make it equal to the server clock.  For example, if the client is
    * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
    */
--- a/services/fxaccounts/FxAccountsClient.jsm
+++ b/services/fxaccounts/FxAccountsClient.jsm
@@ -90,23 +90,52 @@ this.FxAccountsClient.prototype = {
    *        Returns a promise that resolves to an object:
    *        {
    *          uid: the user's unique ID (hex)
    *          sessionToken: a session token (hex)
    *          keyFetchToken: a key fetch token (hex)
    *          verified: flag indicating verification status of the email
    *        }
    */
-  signIn: function signIn(email, password) {
+  signIn: function signIn(email, password, retryOK=true) {
     return Credentials.setup(email, password).then((creds) => {
       let data = {
         email: creds.emailUTF8,
         authPW: CommonUtils.bytesAsHex(creds.authPW),
       };
-      return this._request("/account/login", "POST", null, data);
+      return this._request("/account/login", "POST", null, data).then(
+        // Include the canonical capitalization of the email in the response so
+        // the caller can set its signed-in user state accordingly.
+        result => {
+          result.email = data.email;
+          return result;
+        },
+        error => {
+          log.debug("signIn error: " + JSON.stringify(error));
+          // If the user entered an email with different capitalization from
+          // what's stored in the database (e.g., Greta.Garbo@gmail.COM as
+          // opposed to greta.garbo@gmail.com), the server will respond with a
+          // errno 120 (code 400) and the expected capitalization of the email.
+          // We retry with this email exactly once.  If successful, we use the
+          // server's version of the email as the signed-in-user's email. This
+          // is necessary because the email also serves as salt; so we must be
+          // in agreement with the server on capitalization.
+          //
+          // API reference:
+          // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md
+          if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) {
+            if (!error.email) {
+              log.error("Server returned errno 120 but did not provide email");
+              throw error;
+            }
+            return this.signIn(error.email, password, false);
+          }
+          throw error;
+        }
+      );
     });
   },
 
   /**
    * Destroy the current session with the Firefox Account API server
    *
    * @param sessionTokenHex
    *        The session token encoded in hex
@@ -145,17 +174,17 @@ this.FxAccountsClient.prototype = {
    * 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 (bytes)
-   *          wrapKB: an encryption key that requires knowledge of the 
+   *          wrapKB: an encryption key that requires knowledge of the
    *                  user's password (bytes)
    *        }
    */
   accountKeys: function (keyFetchTokenHex) {
     let creds = this._deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken");
     let keyRequestKey = creds.extra.slice(0, 32);
     let morecreds = CryptoUtils.hkdf(keyRequestKey, undefined,
                                      Credentials.keyWord("account/keys"), 3 * 32);
@@ -310,17 +339,17 @@ this.FxAccountsClient.prototype = {
           deferred.resolve(response);
         } catch (err) {
           log.error("json parse error on response: " + responseText);
           deferred.reject({error: err});
         }
       },
 
       (error) => {
-        log.error("request error: " + JSON.stringify(error));
+        log.error("error " + method + "ing " + path + ": " + JSON.stringify(error));
         deferred.reject(error);
       }
     );
 
     return deferred.promise;
   },
 };
 
--- a/services/fxaccounts/FxAccountsCommon.js
+++ b/services/fxaccounts/FxAccountsCommon.js
@@ -47,77 +47,92 @@ this.ONVERIFIED_NOTIFICATION = "fxaccoun
 this.ONLOGOUT_NOTIFICATION = "fxaccounts:onlogout";
 
 // UI Requests.
 this.UI_REQUEST_SIGN_IN_FLOW = "signInFlow";
 this.UI_REQUEST_REFRESH_AUTH = "refreshAuthentication";
 
 // Server errno.
 // From https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-format
-this.ERRNO_ACCOUNT_ALREADY_EXISTS     = 101;
-this.ERRNO_ACCOUNT_DOES_NOT_EXIST     = 102;
-this.ERRNO_INCORRECT_PASSWORD         = 103;
-this.ERRNO_UNVERIFIED_ACCOUNT         = 104;
-this.ERRNO_INVALID_VERIFICATION_CODE  = 105;
-this.ERRNO_NOT_VALID_JSON_BODY        = 106;
-this.ERRNO_INVALID_BODY_PARAMETERS    = 107;
-this.ERRNO_MISSING_BODY_PARAMETERS    = 108;
-this.ERRNO_INVALID_REQUEST_SIGNATURE  = 109;
-this.ERRNO_INVALID_AUTH_TOKEN         = 110;
-this.ERRNO_INVALID_AUTH_TIMESTAMP     = 111;
-this.ERRNO_MISSING_CONTENT_LENGTH     = 112;
-this.ERRNO_REQUEST_BODY_TOO_LARGE     = 113;
-this.ERRNO_TOO_MANY_CLIENT_REQUESTS   = 114;
-this.ERRNO_INVALID_AUTH_NONCE         = 115;
-this.ERRNO_SERVICE_TEMP_UNAVAILABLE   = 201;
-this.ERRNO_UNKNOWN_ERROR              = 999;
+this.ERRNO_ACCOUNT_ALREADY_EXISTS         = 101;
+this.ERRNO_ACCOUNT_DOES_NOT_EXIST         = 102;
+this.ERRNO_INCORRECT_PASSWORD             = 103;
+this.ERRNO_UNVERIFIED_ACCOUNT             = 104;
+this.ERRNO_INVALID_VERIFICATION_CODE      = 105;
+this.ERRNO_NOT_VALID_JSON_BODY            = 106;
+this.ERRNO_INVALID_BODY_PARAMETERS        = 107;
+this.ERRNO_MISSING_BODY_PARAMETERS        = 108;
+this.ERRNO_INVALID_REQUEST_SIGNATURE      = 109;
+this.ERRNO_INVALID_AUTH_TOKEN             = 110;
+this.ERRNO_INVALID_AUTH_TIMESTAMP         = 111;
+this.ERRNO_MISSING_CONTENT_LENGTH         = 112;
+this.ERRNO_REQUEST_BODY_TOO_LARGE         = 113;
+this.ERRNO_TOO_MANY_CLIENT_REQUESTS       = 114;
+this.ERRNO_INVALID_AUTH_NONCE             = 115;
+this.ERRNO_ENDPOINT_NO_LONGER_SUPPORTED   = 116;
+this.ERRNO_INCORRECT_LOGIN_METHOD         = 117;
+this.ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD = 118;
+this.ERRNO_INCORRECT_API_VERSION          = 119;
+this.ERRNO_INCORRECT_EMAIL_CASE           = 120;
+this.ERRNO_SERVICE_TEMP_UNAVAILABLE       = 201;
+this.ERRNO_UNKNOWN_ERROR                  = 999;
 
 // Errors.
-this.ERROR_ACCOUNT_ALREADY_EXISTS     = "ACCOUNT_ALREADY_EXISTS";
-this.ERROR_ACCOUNT_DOES_NOT_EXIST     = "ACCOUNT_DOES_NOT_EXIST";
-this.ERROR_ALREADY_SIGNED_IN_USER     = "ALREADY_SIGNED_IN_USER";
-this.ERROR_INVALID_ACCOUNTID          = "INVALID_ACCOUNTID";
-this.ERROR_INVALID_AUDIENCE           = "INVALID_AUDIENCE";
-this.ERROR_INVALID_AUTH_TOKEN         = "INVALID_AUTH_TOKEN";
-this.ERROR_INVALID_AUTH_TIMESTAMP     = "INVALID_AUTH_TIMESTAMP";
-this.ERROR_INVALID_AUTH_NONCE         = "INVALID_AUTH_NONCE";
-this.ERROR_INVALID_BODY_PARAMETERS    = "INVALID_BODY_PARAMETERS";
-this.ERROR_INVALID_PASSWORD           = "INVALID_PASSWORD";
-this.ERROR_INVALID_VERIFICATION_CODE  = "INVALID_VERIFICATION_CODE";
-this.ERROR_INVALID_REFRESH_AUTH_VALUE = "INVALID_REFRESH_AUTH_VALUE";
-this.ERROR_INVALID_REQUEST_SIGNATURE  = "INVALID_REQUEST_SIGNATURE";
-this.ERROR_INTERNAL_INVALID_USER      = "INTERNAL_ERROR_INVALID_USER";
-this.ERROR_MISSING_BODY_PARAMETERS    = "MISSING_BODY_PARAMETERS";
-this.ERROR_MISSING_CONTENT_LENGTH     = "MISSING_CONTENT_LENGTH";
-this.ERROR_NO_TOKEN_SESSION           = "NO_TOKEN_SESSION";
-this.ERROR_NOT_VALID_JSON_BODY        = "NOT_VALID_JSON_BODY";
-this.ERROR_OFFLINE                    = "OFFLINE";
-this.ERROR_REQUEST_BODY_TOO_LARGE     = "REQUEST_BODY_TOO_LARGE";
-this.ERROR_SERVER_ERROR               = "SERVER_ERROR";
-this.ERROR_TOO_MANY_CLIENT_REQUESTS   = "TOO_MANY_CLIENT_REQUESTS";
-this.ERROR_SERVICE_TEMP_UNAVAILABLE   = "SERVICE_TEMPORARY_UNAVAILABLE";
-this.ERROR_UI_ERROR                   = "UI_ERROR";
-this.ERROR_UI_REQUEST                 = "UI_REQUEST";
-this.ERROR_UNKNOWN                    = "UNKNOWN_ERROR";
-this.ERROR_UNVERIFIED_ACCOUNT         = "UNVERIFIED_ACCOUNT";
+this.ERROR_ACCOUNT_ALREADY_EXISTS         = "ACCOUNT_ALREADY_EXISTS";
+this.ERROR_ACCOUNT_DOES_NOT_EXIST         = "ACCOUNT_DOES_NOT_EXIST ";
+this.ERROR_ALREADY_SIGNED_IN_USER         = "ALREADY_SIGNED_IN_USER";
+this.ERROR_ENDPOINT_NO_LONGER_SUPPORTED   = "ENDPOINT_NO_LONGER_SUPPORTED";
+this.ERROR_INCORRECT_API_VERSION          = "INCORRECT_API_VERSION";
+this.ERROR_INCORRECT_EMAIL_CASE           = "INCORRECT_EMAIL_CASE";
+this.ERROR_INCORRECT_KEY_RETRIEVAL_METHOD = "INCORRECT_KEY_RETRIEVAL_METHOD";
+this.ERROR_INCORRECT_LOGIN_METHOD         = "INCORRECT_LOGIN_METHOD";
+this.ERROR_INVALID_ACCOUNTID              = "INVALID_ACCOUNTID";
+this.ERROR_INVALID_AUDIENCE               = "INVALID_AUDIENCE";
+this.ERROR_INVALID_AUTH_TOKEN             = "INVALID_AUTH_TOKEN";
+this.ERROR_INVALID_AUTH_TIMESTAMP         = "INVALID_AUTH_TIMESTAMP";
+this.ERROR_INVALID_AUTH_NONCE             = "INVALID_AUTH_NONCE";
+this.ERROR_INVALID_BODY_PARAMETERS        = "INVALID_BODY_PARAMETERS";
+this.ERROR_INVALID_PASSWORD               = "INVALID_PASSWORD";
+this.ERROR_INVALID_VERIFICATION_CODE      = "INVALID_VERIFICATION_CODE";
+this.ERROR_INVALID_REFRESH_AUTH_VALUE     = "INVALID_REFRESH_AUTH_VALUE";
+this.ERROR_INVALID_REQUEST_SIGNATURE      = "INVALID_REQUEST_SIGNATURE";
+this.ERROR_INTERNAL_INVALID_USER          = "INTERNAL_ERROR_INVALID_USER";
+this.ERROR_MISSING_BODY_PARAMETERS        = "MISSING_BODY_PARAMETERS";
+this.ERROR_MISSING_CONTENT_LENGTH         = "MISSING_CONTENT_LENGTH";
+this.ERROR_NO_TOKEN_SESSION               = "NO_TOKEN_SESSION";
+this.ERROR_NOT_VALID_JSON_BODY            = "NOT_VALID_JSON_BODY";
+this.ERROR_OFFLINE                        = "OFFLINE";
+this.ERROR_REQUEST_BODY_TOO_LARGE         = "REQUEST_BODY_TOO_LARGE";
+this.ERROR_SERVER_ERROR                   = "SERVER_ERROR";
+this.ERROR_TOO_MANY_CLIENT_REQUESTS       = "TOO_MANY_CLIENT_REQUESTS";
+this.ERROR_SERVICE_TEMP_UNAVAILABLE       = "SERVICE_TEMPORARY_UNAVAILABLE";
+this.ERROR_UI_ERROR                       = "UI_ERROR";
+this.ERROR_UI_REQUEST                     = "UI_REQUEST";
+this.ERROR_UNKNOWN                        = "UNKNOWN_ERROR";
+this.ERROR_UNVERIFIED_ACCOUNT             = "UNVERIFIED_ACCOUNT";
 
 // Error matching.
 this.SERVER_ERRNO_TO_ERROR = {};
-SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_ALREADY_EXISTS]     = ERROR_ACCOUNT_ALREADY_EXISTS;
-SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_DOES_NOT_EXIST]     = ERROR_ACCOUNT_DOES_NOT_EXIST;
-SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_PASSWORD]         = ERROR_INVALID_PASSWORD;
-SERVER_ERRNO_TO_ERROR[ERRNO_UNVERIFIED_ACCOUNT]         = ERROR_UNVERIFIED_ACCOUNT;
-SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_VERIFICATION_CODE]  = ERROR_INVALID_VERIFICATION_CODE;
-SERVER_ERRNO_TO_ERROR[ERRNO_NOT_VALID_JSON_BODY]        = ERROR_NOT_VALID_JSON_BODY;
-SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_BODY_PARAMETERS]    = ERROR_INVALID_BODY_PARAMETERS;
-SERVER_ERRNO_TO_ERROR[ERRNO_MISSING_BODY_PARAMETERS]    = ERROR_MISSING_BODY_PARAMETERS;
-SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_REQUEST_SIGNATURE]  = ERROR_INVALID_REQUEST_SIGNATURE;
-SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_TOKEN]         = ERROR_INVALID_AUTH_TOKEN;
-SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_TIMESTAMP]     = ERROR_INVALID_AUTH_TIMESTAMP;
-SERVER_ERRNO_TO_ERROR[ERRNO_MISSING_CONTENT_LENGTH]     = ERROR_MISSING_CONTENT_LENGTH;
-SERVER_ERRNO_TO_ERROR[ERRNO_REQUEST_BODY_TOO_LARGE]     = ERROR_REQUEST_BODY_TOO_LARGE;
-SERVER_ERRNO_TO_ERROR[ERRNO_TOO_MANY_CLIENT_REQUESTS]   = ERROR_TOO_MANY_CLIENT_REQUESTS;
-SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_NONCE]         = ERROR_INVALID_AUTH_NONCE;
-SERVER_ERRNO_TO_ERROR[ERRNO_SERVICE_TEMP_UNAVAILABLE]   = ERROR_SERVICE_TEMP_UNAVAILABLE;
-SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_ERROR]              = ERROR_UNKNOWN;
+SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_ALREADY_EXISTS]         = ERROR_ACCOUNT_ALREADY_EXISTS;
+SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_DOES_NOT_EXIST]         = ERROR_ACCOUNT_DOES_NOT_EXIST;
+SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_PASSWORD]             = ERROR_INVALID_PASSWORD;
+SERVER_ERRNO_TO_ERROR[ERRNO_UNVERIFIED_ACCOUNT]             = ERROR_UNVERIFIED_ACCOUNT;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_VERIFICATION_CODE]      = ERROR_INVALID_VERIFICATION_CODE;
+SERVER_ERRNO_TO_ERROR[ERRNO_NOT_VALID_JSON_BODY]            = ERROR_NOT_VALID_JSON_BODY;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_BODY_PARAMETERS]        = ERROR_INVALID_BODY_PARAMETERS;
+SERVER_ERRNO_TO_ERROR[ERRNO_MISSING_BODY_PARAMETERS]        = ERROR_MISSING_BODY_PARAMETERS;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_REQUEST_SIGNATURE]      = ERROR_INVALID_REQUEST_SIGNATURE;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_TOKEN]             = ERROR_INVALID_AUTH_TOKEN;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_TIMESTAMP]         = ERROR_INVALID_AUTH_TIMESTAMP;
+SERVER_ERRNO_TO_ERROR[ERRNO_MISSING_CONTENT_LENGTH]         = ERROR_MISSING_CONTENT_LENGTH;
+SERVER_ERRNO_TO_ERROR[ERRNO_REQUEST_BODY_TOO_LARGE]         = ERROR_REQUEST_BODY_TOO_LARGE;
+SERVER_ERRNO_TO_ERROR[ERRNO_TOO_MANY_CLIENT_REQUESTS]       = ERROR_TOO_MANY_CLIENT_REQUESTS;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_NONCE]             = ERROR_INVALID_AUTH_NONCE;
+SERVER_ERRNO_TO_ERROR[ERRNO_ENDPOINT_NO_LONGER_SUPPORTED]   = ERROR_ENDPOINT_NO_LONGER_SUPPORTED;
+SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_LOGIN_METHOD]         = ERROR_INCORRECT_LOGIN_METHOD;
+SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD] = ERROR_INCORRECT_KEY_RETRIEVAL_METHOD;
+SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_API_VERSION]          = ERROR_INCORRECT_API_VERSION;
+SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_EMAIL_CASE]           = ERROR_INCORRECT_EMAIL_CASE;
+SERVER_ERRNO_TO_ERROR[ERRNO_SERVICE_TEMP_UNAVAILABLE]       = ERROR_SERVICE_TEMP_UNAVAILABLE;
+SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_ERROR]                  = ERROR_UNKNOWN;
 
 // Allow this file to be imported via Components.utils.import().
 this.EXPORTED_SYMBOLS = Object.keys(this);
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/mochitest/chrome.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+support-files=
+  file_invalidEmailCase.sjs
+
+[test_invalidEmailCase.html]
+
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This server simulates the behavior of /account/login on the Firefox Accounts
+ * auth server in the case where the user is trying to sign in with an email
+ * with the wrong capitalization.
+ *
+ * https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#post-v1accountlogin
+ *
+ * The expected behavior is that on the first attempt, with the wrong email,
+ * the server will respond with a 400 and the canonical email capitalization
+ * that the client should use.  The client then has one chance to sign in with
+ * this different capitalization.
+ *
+ * In this test, the user with the account id "Greta.Garbo@gmail.COM" initially
+ * tries to sign in as "greta.garbo@gmail.com".
+ *
+ * On success, the client is responsible for updating its sign-in user state
+ * and recording the proper email capitalization.
+ */
+
+const CC = Components.Constructor;
+const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1",
+                             "nsIBinaryInputStream",
+                             "setInputStream");
+
+const goodEmail = "Greta.Garbo@gmail.COM";
+const badEmail = "greta.garbo@gmail.com";
+
+function handleRequest(request, response) {
+  let body = new BinaryInputStream(request.bodyInputStream);
+  let bytes = [];
+  let available;
+  while ((available = body.available()) > 0) {
+    Array.prototype.push.apply(bytes, body.readByteArray(available));
+  }
+
+  let data = JSON.parse(String.fromCharCode.apply(null, bytes));
+  let message;
+
+  switch (data.email) {
+    case badEmail:
+      // Almost - try again with fixed email case
+      message = {
+        code: 400,
+        errno: 120,
+        error: "Incorrect email case",
+        email: goodEmail,
+      };
+      response.setStatusLine(request.httpVersion, 400, "Almost");
+      break;
+
+    case goodEmail:
+      // Successful login.
+      message = {
+        uid: "your-uid",
+        sessionToken: "your-sessionToken",
+        keyFetchToken: "your-keyFetchToken",
+        verified: true,
+        authAt: 1392144866,
+      };
+      response.setStatusLine(request.httpVersion, 200, "Yay");
+      break;
+
+    default:
+      // Anything else happening in this test is a failure.
+      message = {
+        code: 400,
+        errno: 999,
+        error: "What happened!?",
+      };
+      response.setStatusLine(request.httpVersion, 400, "Ouch");
+      break;
+  }
+
+  messageStr = JSON.stringify(message);
+  response.bodyOutputStream.write(messageStr, messageStr.length);
+}
+
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html
@@ -0,0 +1,126 @@
+<!--
+     Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/
+-->
+<!DOCTYPE HTML>
+<html>
+<!--
+Tests for Firefox Accounts signin with invalid email case
+https://bugzilla.mozilla.org/show_bug.cgi?id=963835
+-->
+<head>
+  <title>Test for Firefox Accounts (Bug 963835)</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=963835">Mozilla Bug 963835</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+  Test for correction of invalid email case in Fx Accounts signIn
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript;version=1.8">
+
+SimpleTest.waitForExplicitFinish();
+
+Components.utils.import("resource://gre/modules/Promise.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/FxAccounts.jsm");
+Components.utils.import("resource://gre/modules/FxAccountsClient.jsm");
+Components.utils.import("resource://services-common/hawk.js");
+
+const TEST_SERVER =
+  "http://mochi.test:8888/chrome/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs?path=";
+
+let MockStorage = function() {
+  this.data = null;
+};
+MockStorage.prototype = Object.freeze({
+  set: function (contents) {
+    this.data = contents;
+    return Promise.resolve(null);
+  },
+  get: function () {
+    return Promise.resolve(this.data);
+  },
+});
+
+function MockFxAccounts() {
+  return new FxAccounts({
+    _now_is: new Date(),
+
+    now: function() {
+      return this._now_is;
+    },
+
+    signedInUserStorage: new MockStorage(),
+
+    fxAccountsClient: new FxAccountsClient(TEST_SERVER),
+  });
+}
+
+let wrongEmail = "greta.garbo@gmail.com";
+let rightEmail = "Greta.Garbo@gmail.COM";
+let password = "123456";
+
+function runTest() {
+  is(Services.prefs.getCharPref("identity.fxaccounts.auth.uri"), TEST_SERVER,
+     "Pref for auth.uri should be set to test server");
+
+  let fxa = new MockFxAccounts();
+  let client = fxa.internal.fxAccountsClient;
+
+  ok(true, !!fxa, "Couldn't mock fxa");
+  ok(true, !!client, "Couldn't mock fxa client");
+  is(client.host, TEST_SERVER, "Should be using the test auth server uri");
+
+  // First try to sign in using the email with the wrong capitalization.  The
+  // FxAccountsClient will receive a 400 from the server with the corrected email.
+  // It will automatically try to sign in again.  We expect this to succeed.
+  client.signIn(wrongEmail, password).then(
+    user => {
+
+      // Now store the signed-in user state.  This will include the correct
+      // email capitalization.
+      fxa.setSignedInUser(user).then(
+        () => {
+
+          // Confirm that the correct email got stored.
+          fxa.getSignedInUser().then(
+            data => {
+              is(data.email, rightEmail);
+              SimpleTest.finish();
+            },
+            getUserError => {
+              ok(false, JSON.stringify(getUserError));
+            }
+          );
+        },
+        setSignedInUserError => {
+          ok(false, JSON.stringify(setSignedInUserError));
+        }
+      );
+    },
+    signInError => {
+      ok(false, JSON.stringify(signInError));
+    }
+  );
+};
+
+SpecialPowers.pushPrefEnv({"set": [
+    ["dom.identity.enabled", true],                // navigator.mozId
+    ["identity.fxaccounts.enabled", true],         // fx accounts
+    ["identity.fxaccounts.auth.uri", TEST_SERVER], // our sjs server
+    ["toolkit.identity.debug", true],              // verbose identity logging
+    ["browser.dom.window.dump.enabled", true],
+  ]},
+  function () { runTest(); }
+);
+
+</script>
+</pre>
+</body>
+</html>
+
--- a/services/fxaccounts/tests/moz.build
+++ b/services/fxaccounts/tests/moz.build
@@ -1,7 +1,9 @@
 # -*- 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']
+
+MOCHITEST_CHROME_MANIFESTS += ['mochitest/chrome.ini']
--- a/services/fxaccounts/tests/xpcshell/test_client.js
+++ b/services/fxaccounts/tests/xpcshell/test_client.js
@@ -390,17 +390,17 @@ add_task(function test_signCertificate()
         do_check_eq(JSON.parse(jsonBody.publicKey).foo, "bar");
         do_check_eq(jsonBody.duration, 600);
         response.setStatusLine(request.httpVersion, 200, "OK");
         return response.bodyOutputStream.write(certSignMessage, certSignMessage.length);
       }
 
       // Second attempt, trigger error
       response.setStatusLine(request.httpVersion, 400, "Bad request");
-      response.bodyOutputStream.write(errorMessage, errorMessage.length);
+      return response.bodyOutputStream.write(errorMessage, errorMessage.length);
     },
   });
 
   let client = new FxAccountsClient(server.baseURI);
   let result = yield client.signCertificate(FAKE_SESSION_TOKEN, JSON.stringify({foo: "bar"}), 600);
   do_check_eq("baz", result.bar);
 
   // Account doesn't exist
@@ -478,12 +478,65 @@ add_task(function test_accountExists() {
     result = yield client.accountExists("i.break.things@example.com");
   } catch(unexpectedError) {
     do_check_eq(unexpectedError.code, 500);
   }
 
   yield deferredStop(server);
 });
 
+add_task(function test_email_case() {
+  let canonicalEmail = "greta.garbo@gmail.com";
+  let clientEmail = "Greta.Garbo@gmail.COM";
+  let attempts = 0;
+
+  function writeResp(response, msg) {
+    if (typeof msg === "object") {
+      msg = JSON.stringify(msg);
+    }
+    response.bodyOutputStream.write(msg, msg.length);
+  }
+
+  let server = httpd_setup(
+    {
+      "/account/login": function(request, response) {
+        response.setHeader("Content-Type", "application/json; charset=utf-8");
+        attempts += 1;
+        if (attempts > 2) {
+          response.setStatusLine(request.httpVersion, 429, "Sorry, you had your chance");
+          return writeResp(response, "");
+        }
+
+        let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+        let jsonBody = JSON.parse(body);
+        let email = jsonBody.email;
+
+        // If the client has the wrong case on the email, we return a 400, with
+        // the capitalization of the email as saved in the accounts database.
+        if (email == canonicalEmail) {
+          response.setStatusLine(request.httpVersion, 200, "Yay");
+          return writeResp(response, {areWeHappy: "yes"});
+        }
+
+        response.setStatusLine(request.httpVersion, 400, "Incorrect email case");
+        return writeResp(response, {
+          code: 400,
+          errno: 120,
+          error: "Incorrect email case",
+          email: canonicalEmail
+        });
+      },
+    }
+  );
+
+  let client = new FxAccountsClient(server.baseURI);
+
+  let result = yield client.signIn(clientEmail, "123456");
+  do_check_eq(result.areWeHappy, "yes");
+  do_check_eq(attempts, 2);
+
+  yield deferredStop(server);
+});
+
 // turn formatted test vectors into normal hex strings
 function h(hexStr) {
   return hexStr.replace(/\s+/g, "");
 }