Bug 1065052 - Implement modified Loop FxA registration flow using a second Loop registration. r=abr,jaws
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Wed, 10 Sep 2014 01:48:54 -0700
changeset 225336 97040a73debbd341d92812a6e9db1b5c266bb14a
parent 225335 1ae1483284dcdc51ac07d7c063d5ada4ad9a1741
child 225337 dbfa16c4d3ccb4d06199012563bd7b9accfa7996
push id3979
push userraliiev@mozilla.com
push dateMon, 13 Oct 2014 16:35:44 +0000
treeherdermozilla-beta@30f2cc610691 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersabr, jaws
bugs1065052
milestone34.0a2
Bug 1065052 - Implement modified Loop FxA registration flow using a second Loop registration. r=abr,jaws
browser/components/loop/MozLoopAPI.jsm
browser/components/loop/MozLoopService.jsm
browser/components/loop/content/js/client.js
browser/components/loop/test/mochitest/browser_fxa_login.js
browser/components/loop/test/mochitest/head.js
browser/components/loop/test/mochitest/loop_fxa.sjs
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -333,25 +333,34 @@ function injectLoopAPI(targetWindow) {
      * @param {Object} payloadObj An object which is converted to JSON and
      *                            transmitted with the request.
      * @param {Function} callback Called when the request completes.
      */
     hawkRequest: {
       enumerable: true,
       writable: true,
       value: function(path, method, payloadObj, callback) {
+        // XXX: Bug 1065153 - Should take a sessionType parameter instead of hard-coding GUEST
         // XXX Should really return a DOM promise here.
-        return MozLoopService.hawkRequest(path, method, payloadObj).then((response) => {
+        return MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, path, method, payloadObj).then((response) => {
           callback(null, response.body);
         }, (error) => {
           callback(Cu.cloneInto(error, targetWindow));
         });
       }
     },
 
+    LOOP_SESSION_TYPE: {
+      enumerable: true,
+      writable: false,
+      value: function() {
+        return LOOP_SESSION_TYPE;
+      },
+    },
+
     logInToFxA: {
       enumerable: true,
       writable: true,
       value: function() {
         return MozLoopService.logInToFxA();
       }
     },
 
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -10,24 +10,29 @@ const { classes: Cc, interfaces: Ci, uti
 // https://github.com/mozilla-services/loop-server/blob/45787d34108e2f0d87d74d4ddf4ff0dbab23501c/loop/errno.json#L6
 const INVALID_AUTH_TOKEN = 110;
 
 // Ticket numbers are 24 bits in length.
 // The highest valid ticket number is 16777214 (2^24 - 2), so that a "now
 // serving" number of 2^24 - 1 is greater than it.
 const MAX_SOFT_START_TICKET_NUMBER = 16777214;
 
+const LOOP_SESSION_TYPE = {
+  GUEST: 1,
+  FXA: 2,
+};
+
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/osfile.jsm", this);
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/FxAccountsOAuthClient.jsm");
 
-this.EXPORTED_SYMBOLS = ["MozLoopService"];
+this.EXPORTED_SYMBOLS = ["MozLoopService", "LOOP_SESSION_TYPE"];
 
 XPCOMUtils.defineLazyModuleGetter(this, "console",
   "resource://gre/modules/devtools/Console.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "injectLoopAPI",
   "resource:///modules/loop/MozLoopAPI.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "convertToRTCStatsReport",
@@ -197,34 +202,36 @@ let MozLoopServiceInternal = {
       this.onHandleNotification.bind(this));
 
     return result;
   },
 
   /**
    * Performs a hawk based request to the loop server.
    *
+   * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
+   *                                        This is one of the LOOP_SESSION_TYPE members.
    * @param {String} path The path to make the request to.
    * @param {String} method The request method, e.g. 'POST', 'GET'.
    * @param {Object} payloadObj An object which is converted to JSON and
    *                            transmitted with the request.
    * @returns {Promise}
    *        Returns a promise that resolves to the response of the API call,
    *        or is rejected with an error.  If the server response can be parsed
    *        as JSON and contains an 'error' property, the promise will be
    *        rejected with this JSON-parsed response.
    */
-  hawkRequest: function(path, method, payloadObj) {
+  hawkRequest: function(sessionType, path, method, payloadObj) {
     if (!gHawkClient) {
       gHawkClient = new HawkClient(this.loopServerUri);
     }
 
     let sessionToken;
     try {
-      sessionToken = Services.prefs.getCharPref("loop.hawk-session-token");
+      sessionToken = Services.prefs.getCharPref(this.getSessionTokenPrefName(sessionType));
     } catch (x) {
       // It is ok for this not to exist, we'll default to sending no-creds
     }
 
     let credentials;
     if (sessionToken) {
       // true = use a hex key, as required by the server (see bug 1032738).
       credentials = deriveHawkCredentials(sessionToken, "sessionToken",
@@ -232,29 +239,47 @@ let MozLoopServiceInternal = {
     }
 
     return gHawkClient.request(path, method, credentials, payloadObj).catch(error => {
       console.error("Loop hawkRequest error:", error);
       throw error;
     });
   },
 
+  getSessionTokenPrefName: function(sessionType) {
+    let suffix;
+    switch (sessionType) {
+      case LOOP_SESSION_TYPE.GUEST:
+        suffix = "";
+        break;
+      case LOOP_SESSION_TYPE.FXA:
+        suffix = ".fxa";
+        break;
+      default:
+        throw new Error("Unknown LOOP_SESSION_TYPE");
+        break;
+    }
+    return "loop.hawk-session-token" + suffix;
+  },
+
   /**
    * Used to store a session token from a request if it exists in the headers.
    *
+   * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
+   *                                        One of the LOOP_SESSION_TYPE members.
    * @param {Object} headers The request headers, which may include a
    *                         "hawk-session-token" to be saved.
    * @return true on success or no token, false on failure.
    */
-  storeSessionToken: function(headers) {
+  storeSessionToken: function(sessionType, headers) {
     let sessionToken = headers["hawk-session-token"];
     if (sessionToken) {
       // XXX should do more validation here
       if (sessionToken.length === 64) {
-        Services.prefs.setCharPref("loop.hawk-session-token", sessionToken);
+        Services.prefs.setCharPref(this.getSessionTokenPrefName(sessionType), sessionToken);
       } else {
         // XXX Bubble the precise details up to the UI somehow (bug 1013248).
         console.warn("Loop server sent an invalid session token");
         gRegisteredDeferred.reject("session-token-wrong-size");
         gRegisteredDeferred = null;
         return false;
       }
     }
@@ -269,59 +294,70 @@ let MozLoopServiceInternal = {
    */
   onPushRegistered: function(err, pushUrl) {
     if (err) {
       gRegisteredDeferred.reject(err);
       gRegisteredDeferred = null;
       return;
     }
 
-    this.registerWithLoopServer(pushUrl);
+    this.registerWithLoopServer(LOOP_SESSION_TYPE.GUEST, pushUrl).then(() => {
+      // storeSessionToken could have rejected and nulled the promise if the token was malformed.
+      if (!gRegisteredDeferred) {
+        return;
+      }
+      gRegisteredDeferred.resolve();
+      // No need to clear the promise here, everything was good, so we don't need
+      // to re-register.
+    }, (error) => {
+      Cu.reportError("Failed to register with Loop server: " + error.errno);
+      gRegisteredDeferred.reject(error.errno);
+      gRegisteredDeferred = null;
+    });
   },
 
   /**
-   * Registers with the Loop server.
+   * Registers with the Loop server either as a guest or a FxA user.
    *
+   * @param {LOOP_SESSION_TYPE} sessionType The type of session e.g. guest or FxA
    * @param {String} pushUrl The push url given by the push server.
-   * @param {Boolean} noRetry Optional, don't retry if authentication fails.
+   * @param {Boolean} [retry=true] Whether to retry if authentication fails.
+   * @return {Promise}
    */
-  registerWithLoopServer: function(pushUrl, noRetry) {
-    this.hawkRequest("/registration", "POST", { simplePushURL: pushUrl})
+  registerWithLoopServer: function(sessionType, pushUrl, retry = true) {
+    return this.hawkRequest(sessionType, "/registration", "POST", { simplePushURL: pushUrl})
       .then((response) => {
         // If this failed we got an invalid token. storeSessionToken rejects
         // the gRegisteredDeferred promise for us, so here we just need to
         // early return.
-        if (!this.storeSessionToken(response.headers))
+        if (!this.storeSessionToken(sessionType, response.headers))
           return;
 
         this.clearError("registration");
-        gRegisteredDeferred.resolve();
-        // No need to clear the promise here, everything was good, so we don't need
-        // to re-register.
       }, (error) => {
         // There's other errors than invalid auth token, but we should only do the reset
         // as a last resort.
         if (error.code === 401 && error.errno === INVALID_AUTH_TOKEN) {
           if (this.urlExpiryTimeIsInFuture()) {
             // XXX Should this be reported to the user is a visible manner?
             Cu.reportError("Loop session token is invalid, all previously "
                            + "generated urls will no longer work.");
           }
 
           // Authorization failed, invalid token, we need to try again with a new token.
-          Services.prefs.clearUserPref("loop.hawk-session-token");
-          this.registerWithLoopServer(pushUrl, true);
-          return;
+          Services.prefs.clearUserPref(this.getSessionTokenPrefName(sessionType));
+          if (retry) {
+            return this.registerWithLoopServer(sessionType, pushUrl, false);
+          }
         }
 
         // XXX Bubble the precise details up to the UI somehow (bug 1013248).
         Cu.reportError("Failed to register with the loop server. error: " + error);
         this.setError("registration", error);
-        gRegisteredDeferred.reject(error.errno);
-        gRegisteredDeferred = null;
+        throw error;
       }
     );
   },
 
   /**
    * Callback from MozLoopPushHandler - A push notification has been received from
    * the server.
    *
@@ -504,17 +540,17 @@ let MozLoopServiceInternal = {
   },
 
   /**
    * Fetch Firefox Accounts (FxA) OAuth parameters from the Loop Server.
    *
    * @return {Promise} resolved with the body of the hawk request for OAuth parameters.
    */
   promiseFxAOAuthParameters: function() {
-    return this.hawkRequest("/fxa-oauth/params", "POST").then(response => {
+    return this.hawkRequest(LOOP_SESSION_TYPE.FXA, "/fxa-oauth/params", "POST").then(response => {
       return JSON.parse(response.body);
     });
   },
 
   /**
    * Get the OAuth client constructed with Loop OAauth parameters.
    *
    * @return {Promise}
@@ -582,17 +618,17 @@ let MozLoopServiceInternal = {
     if (!code || !state) {
       throw new Error("promiseFxAOAuthToken: code and state are required.");
     }
 
     let payload = {
       code: code,
       state: state,
     };
-    return this.hawkRequest("/fxa-oauth/token", "POST", payload).then(response => {
+    return this.hawkRequest(LOOP_SESSION_TYPE.FXA, "/fxa-oauth/token", "POST", payload).then(response => {
       return JSON.parse(response.body);
     });
   },
 
   /**
    * Called once gFxAOAuthClient fires onComplete.
    *
    * @param {Deferred} deferred used to resolve or reject the gFxAOAuthClientPromise
@@ -916,33 +952,44 @@ this.MozLoopService = {
       return Promise.resolve(gFxAOAuthTokenData);
     }
 
     return MozLoopServiceInternal.promiseFxAOAuthAuthorization().then(response => {
       return MozLoopServiceInternal.promiseFxAOAuthToken(response.code, response.state);
     }).then(tokenData => {
       gFxAOAuthTokenData = tokenData;
       return tokenData;
+    }).then(tokenData => {
+      return gRegisteredDeferred.promise.then(Task.async(function*() {
+        if (gPushHandler.pushUrl) {
+          yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, gPushHandler.pushUrl);
+        } else {
+          throw new Error("No pushUrl for FxA registration");
+        }
+        return gFxAOAuthTokenData;
+      }));
     },
     error => {
       gFxAOAuthTokenData = null;
       throw error;
     });
   },
 
   /**
    * Performs a hawk based request to the loop server.
    *
+   * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
+   *                                        One of the LOOP_SESSION_TYPE members.
    * @param {String} path The path to make the request to.
    * @param {String} method The request method, e.g. 'POST', 'GET'.
    * @param {Object} payloadObj An object which is converted to JSON and
    *                            transmitted with the request.
    * @returns {Promise}
    *        Returns a promise that resolves to the response of the API call,
    *        or is rejected with an error.  If the server response can be parsed
    *        as JSON and contains an 'error' property, the promise will be
    *        rejected with this JSON-parsed response.
    */
-  hawkRequest: function(path, method, payloadObj) {
+  hawkRequest: function(sessionType, path, method, payloadObj) {
     return MozLoopServiceInternal.hawkRequest(path, method, payloadObj);
   },
 };
 Object.freeze(this.MozLoopService);
--- a/browser/components/loop/content/js/client.js
+++ b/browser/components/loop/content/js/client.js
@@ -99,17 +99,16 @@ loop.Client = (function($) {
      * Internal handler for requesting a call url from the server.
      *
      * Callback parameters:
      * - err null on successful registration, non-null otherwise.
      * - callUrlData an object of the obtained call url data if successful:
      * -- callUrl: The url of the call
      * -- expiresAt: The amount of hours until expiry of the url
      *
-     * @param  {String} simplepushUrl a registered Simple Push URL
      * @param  {string} nickname the nickname of the future caller
      * @param  {Function} cb Callback(err, callUrlData)
      */
     _requestCallUrlInternal: function(nickname, cb) {
       this.mozLoop.hawkRequest("/call-url/", "POST", {callerId: nickname},
                                function (error, responseText) {
         if (error) {
           this._failureHandler(cb, error);
--- a/browser/components/loop/test/mochitest/browser_fxa_login.js
+++ b/browser/components/loop/test/mochitest/browser_fxa_login.js
@@ -2,27 +2,34 @@
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 /**
  * Test FxA logins with Loop.
  */
 
 "use strict";
 
-const gFxAOAuthTokenData = Cu.import("resource:///modules/loop/MozLoopService.jsm", {}).gFxAOAuthTokenData;
+const {
+  LOOP_SESSION_TYPE,
+  gFxAOAuthTokenData
+} = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
+
 const BASE_URL = "http://mochi.test:8888/browser/browser/components/loop/test/mochitest/loop_fxa.sjs?";
+const HAWK_TOKEN_LENGTH = 64;
 
 add_task(function* setup() {
   Services.prefs.setCharPref("loop.server", BASE_URL);
   Services.prefs.setCharPref("services.push.serverURL", "ws://localhost/");
   registerCleanupFunction(function* () {
     info("cleanup time");
     yield promiseDeletedOAuthParams(BASE_URL);
     Services.prefs.clearUserPref("loop.server");
     Services.prefs.clearUserPref("services.push.serverURL");
+    Services.prefs.clearUserPref(MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.GUEST));
+    Services.prefs.clearUserPref(MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA));
   });
 });
 
 add_task(function* checkOAuthParams() {
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
@@ -156,33 +163,51 @@ add_task(function* basicAuthorizationAnd
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
 
+  info("registering");
+  mockPushHandler.pushUrl = "https://localhost/pushUrl/guest";
+  yield MozLoopService.register(mockPushHandler);
+  let prefName = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.GUEST);
+  let padding = new Array(HAWK_TOKEN_LENGTH - mockPushHandler.pushUrl.length).fill("X").join("");
+  ise(Services.prefs.getCharPref(prefName), mockPushHandler.pushUrl + padding, "Check guest hawk token");
+
+  // Normally the same pushUrl would be registered but we change it in the test
+  // to be able to check for success on the second registration.
+  mockPushHandler.pushUrl = "https://localhost/pushUrl/fxa";
+
   let tokenData = yield MozLoopService.logInToFxA();
   ise(tokenData.access_token, "code1_access_token", "Check access_token");
   ise(tokenData.scope, "profile", "Check scope");
   ise(tokenData.token_type, "bearer", "Check token_type");
+
+  let registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
+  ise(registrationResponse.response.simplePushURL, "https://localhost/pushUrl/fxa", "Check registered push URL");
+  prefName = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
+  padding = new Array(HAWK_TOKEN_LENGTH - mockPushHandler.pushUrl.length).fill("X").join("");
+  ise(Services.prefs.getCharPref(prefName), mockPushHandler.pushUrl + padding, "Check FxA hawk token");
 });
 
 add_task(function* loginWithParams401() {
   resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
     test_error: "params_401",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
+  yield MozLoopService.register(mockPushHandler);
 
   let loginPromise = MozLoopService.logInToFxA();
   yield loginPromise.then(tokenData => {
     ok(false, "Promise should have rejected");
   },
   error => {
     ise(error.code, 401, "Check error code");
     ise(gFxAOAuthTokenData, null, "Check there is no saved token data");
--- a/browser/components/loop/test/mochitest/head.js
+++ b/browser/components/loop/test/mochitest/head.js
@@ -107,8 +107,51 @@ function promiseDeletedOAuthParams(baseU
               createInstance(Ci.nsIXMLHttpRequest);
   xhr.open("DELETE", baseURL + "/setup_params", true);
   xhr.addEventListener("load", () => deferred.resolve(xhr));
   xhr.addEventListener("error", deferred.reject);
   xhr.send();
 
   return deferred.promise;
 }
+
+/**
+ * Get the last registration on the test server.
+ */
+function promiseOAuthGetRegistration(baseURL) {
+  let deferred = Promise.defer();
+  let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
+              createInstance(Ci.nsIXMLHttpRequest);
+  xhr.open("GET", baseURL + "/get_registration", true);
+  xhr.responseType = "json";
+  xhr.addEventListener("load", () => deferred.resolve(xhr));
+  xhr.addEventListener("error", deferred.reject);
+  xhr.send();
+
+  return deferred.promise;
+}
+
+/**
+ * This is used to fake push registration and notifications for
+ * MozLoopService tests. There is only one object created per test instance, as
+ * once registration has taken place, the object cannot currently be changed.
+ */
+let mockPushHandler = {
+  // This sets the registration result to be returned when initialize
+  // is called. By default, it is equivalent to success.
+  registrationResult: null,
+  pushUrl: undefined,
+
+  /**
+   * MozLoopPushHandler API
+   */
+  initialize: function(registerCallback, notificationCallback) {
+    registerCallback(this.registrationResult, this.pushUrl);
+    this._notificationCallback = notificationCallback;
+  },
+
+  /**
+   * Test-only API to simplify notifying a push notification result.
+   */
+  notify: function(version) {
+    this._notificationCallback(version);
+  }
+};
--- a/browser/components/loop/test/mochitest/loop_fxa.sjs
+++ b/browser/components/loop/test/mochitest/loop_fxa.sjs
@@ -3,37 +3,44 @@
 
 /**
  * This is a mock server that implements the FxA endpoints on the Loop server.
  */
 
 "use strict";
 
 const REQUIRED_PARAMS = ["client_id", "content_uri", "oauth_uri", "profile_uri", "state"];
+const HAWK_TOKEN_LENGTH = 64;
 
 Components.utils.import("resource://gre/modules/NetUtil.jsm");
 
 /**
  * Entry point for HTTP requests.
  */
 function handleRequest(request, response) {
   // Look at the query string but ignore past the encoded ? when deciding on the handler.
   switch (request.queryString.replace(/%3F.*/,"")) {
-    case "/setup_params":
+    case "/setup_params": // Test-only
       setup_params(request, response);
       return;
     case "/fxa-oauth/params":
       params(request, response);
       return;
     case encodeURIComponent("/oauth/authorization"):
       oauth_authorization(request, response);
       return;
     case "/fxa-oauth/token":
       token(request, response);
       return;
+    case "/registration":
+      registration(request, response);
+      return;
+    case "/get_registration": // Test-only
+      get_registration(request, response);
+      return;
   }
   response.setStatusLine(request.httpVersion, 404, "Not Found");
 }
 
 /**
  * POST /setup_params
  * DELETE /setup_params
  *
@@ -42,16 +49,17 @@ function handleRequest(request, response
  * For a POST the X-Params header should contain a JSON object with keys to set for /fxa-oauth/params.
  * A DELETE request will delete the stored parameters and should be run in a cleanup function to
  * avoid interfering with subsequen tests.
  */
 function setup_params(request, response) {
   response.setHeader("Content-Type", "text/plain", false);
   if (request.method == "DELETE") {
     setSharedState("/fxa-oauth/params", "");
+    setSharedState("/registration", "");
     response.write("Params deleted");
     return;
   }
   let params = JSON.parse(request.getHeader("X-Params"));
   if (!params) {
     response.setStatusLine(request.httpVersion, 400, "Bad Request");
     return;
   }
@@ -136,8 +144,35 @@ function token(request, response) {
   let tokenData = {
     access_token: payload.code + "_access_token",
     scope: "profile",
     token_type: "bearer",
   };
   response.setHeader("Content-Type", "application/json; charset=utf-8", false);
   response.write(JSON.stringify(tokenData, null, 2));
 }
+
+/**
+ * POST /registration
+ *
+ * Mock Loop registration endpoint which simply returns the simplePushURL with
+ * padding as the hawk session token.
+ */
+function registration(request, response) {
+  let body = NetUtil.readInputStreamToString(request.bodyInputStream,
+                                             request.bodyInputStream.available());
+  let payload = JSON.parse(body);
+  setSharedState("/registration", body);
+  let pushURL = payload.simplePushURL;
+  // Pad the pushURL with "X" to the token length to simulate a token
+  let padding = new Array(HAWK_TOKEN_LENGTH - pushURL.length).fill("X").join("");
+  response.setHeader("hawk-session-token", pushURL + padding, false);
+}
+
+/**
+ * GET /get_registration
+ *
+ * Used for testing purposes to check if registration succeeded by returning the POST body.
+ */
+function get_registration(request, response) {
+  response.setHeader("Content-Type", "application/json; charset=utf-8", false);
+  response.write(getSharedState("/registration"));
+}