Bug 1047130 - Implement desktop backend to login to FxA via OAuth for Loop. r=vladikoff,ckarlof,mikedeboer
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Mon, 18 Aug 2014 14:32:34 -0700
changeset 200250 135772a1b8306302fabb4fccab96a10f03e5dc2f
parent 200249 0771b645710575aeb51d3676447fd5464f604cdb
child 200251 5e615c0e6b3058c625dea03d2d3200bce4771187
push id27338
push useremorley@mozilla.com
push dateTue, 19 Aug 2014 13:33:27 +0000
treeherdermozilla-central@cd2d406df655 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersvladikoff, ckarlof, mikedeboer
bugs1047130
milestone34.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 1047130 - Implement desktop backend to login to FxA via OAuth for Loop. r=vladikoff,ckarlof,mikedeboer This implements the OAuth authorization phase.
browser/components/loop/MozLoopService.jsm
browser/components/loop/moz.build
browser/components/loop/test/mochitest/browser.ini
browser/components/loop/test/mochitest/browser_fxa_login.js
browser/components/loop/test/mochitest/head.js
browser/components/loop/test/mochitest/loop_fxa.sjs
services/fxaccounts/FxAccountsOAuthClient.jsm
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -9,16 +9,18 @@ const { classes: Cc, interfaces: Ci, uti
 // Invalid auth token as per
 // https://github.com/mozilla-services/loop-server/blob/45787d34108e2f0d87d74d4ddf4ff0dbab23501c/loop/errno.json#L6
 const INVALID_AUTH_TOKEN = 110;
 
 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"];
 
 XPCOMUtils.defineLazyModuleGetter(this, "console",
   "resource://gre/modules/devtools/Console.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "injectLoopAPI",
   "resource:///modules/loop/MozLoopAPI.jsm");
@@ -51,16 +53,18 @@ XPCOMUtils.defineLazyServiceGetter(this,
 // or the registration was successful. This is null if a registration attempt was
 // unsuccessful.
 let gRegisteredDeferred = null;
 let gPushHandler = null;
 let gHawkClient = null;
 let gRegisteredLoopServer = false;
 let gLocalizedStrings =  null;
 let gInitializeTimer = null;
+let gFxAOAuthClientPromise = null;
+let gFxAOAuthClient = null;
 
 /**
  * Internal helper methods and state
  *
  * The registration is a two-part process. First we need to connect to
  * and register with the push server. Then we need to take the result of that
  * and register with the Loop server.
  */
@@ -444,17 +448,96 @@ let MozLoopServiceInternal = {
         };
 
         let pc_static = new window.mozRTCPeerConnectionStatic();
         pc_static.registerPeerConnectionLifecycleCallback(onPCLifecycleChange);
       }.bind(this), true);
     };
 
     Chat.open(contentWindow, origin, title, url, undefined, undefined, callback);
-  }
+  },
+
+  /**
+   * 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 JSON.parse(response.body);
+    });
+  },
+
+  /**
+   * Get the OAuth client constructed with Loop OAauth parameters.
+   *
+   * @return {Promise}
+   */
+  promiseFxAOAuthClient: Task.async(function* () {
+    // We must make sure to have only a single client otherwise they will have different states and
+    // multiple channels. This would happen if the user clicks the Login button more than once.
+    if (gFxAOAuthClientPromise) {
+      return gFxAOAuthClientPromise;
+    }
+
+    gFxAOAuthClientPromise = this.promiseFxAOAuthParameters().then(
+      parameters => {
+        try {
+          gFxAOAuthClient = new FxAccountsOAuthClient({
+            parameters: parameters,
+          });
+        } catch (ex) {
+          gFxAOAuthClientPromise = null;
+          throw ex;
+        }
+        return gFxAOAuthClient;
+      },
+      error => {
+        gFxAOAuthClientPromise = null;
+        return error;
+      }
+    );
+
+    return gFxAOAuthClientPromise;
+  }),
+
+  /**
+   * Params => web flow => code
+   */
+  promiseFxAOAuthAuthorization: function() {
+    let deferred = Promise.defer();
+    this.promiseFxAOAuthClient().then(
+      client => {
+        client.onComplete = this._fxAOAuthComplete.bind(this, deferred);
+        client.launchWebFlow();
+      },
+      error => {
+        Cu.reportError(error);
+        deferred.reject(error);
+      }
+    );
+    return deferred.promise;
+  },
+
+  /**
+   * Called once gFxAOAuthClient fires onComplete.
+   *
+   * @param {Deferred} deferred used to resolve or reject the gFxAOAuthClientPromise
+   * @param {Object} result (with code and state)
+   */
+  _fxAOAuthComplete: function(deferred, result) {
+    gFxAOAuthClientPromise = null;
+
+    // Note: The state was already verified in FxAccountsOAuthClient.
+    if (result) {
+      deferred.resolve(result);
+    } else {
+      deferred.reject("Invalid token data");
+    }
+  },
 };
 Object.freeze(MozLoopServiceInternal);
 
 let gInitializeTimerFunc = () => {
   // Kick off the push notification service into registering after a timeout
   // this ensures we're not doing too much straight after the browser's finished
   // starting up.
   gInitializeTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
@@ -464,16 +547,27 @@ let gInitializeTimerFunc = () => {
   },
   MozLoopServiceInternal.initialRegistrationDelayMilliseconds, Ci.nsITimer.TYPE_ONE_SHOT);
 };
 
 /**
  * Public API
  */
 this.MozLoopService = {
+#ifdef DEBUG
+  // Test-only helpers
+  get internal() {
+    return MozLoopServiceInternal;
+  },
+
+  resetFxA: function() {
+    gFxAOAuthClientPromise = null;
+  },
+#endif
+
   set initializeTimerFunc(value) {
     gInitializeTimerFunc = value;
   },
 
   /**
    * Initialized the loop service, and starts registration with the
    * push and loop servers.
    */
@@ -632,16 +726,29 @@ this.MozLoopService = {
     } catch (ex) {
       console.log("getLoopBoolPref had trouble getting " + prefName +
         "; exception: " + ex);
       return null;
     }
   },
 
   /**
+   * Start the FxA login flow using the OAuth client and params from the Loop server.
+   *
+   * The caller should be prepared to handle rejections related to network, server or login errors.
+   *
+   * @return {Promise} that resolves when the FxA login flow is complete.
+   */
+  logInToFxA: function() {
+    return MozLoopServiceInternal.promiseFxAOAuthAuthorization().then(response => {
+      return MozLoopServiceInternal.promiseFxAOAuthToken(response.code, response.state);
+    });
+  },
+
+  /**
    * Performs a hawk based request to the loop server.
    *
    * @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,
--- a/browser/components/loop/moz.build
+++ b/browser/components/loop/moz.build
@@ -10,11 +10,14 @@ XPCSHELL_TESTS_MANIFESTS += ['test/xpcsh
 
 BROWSER_CHROME_MANIFESTS += [
     'test/mochitest/browser.ini',
 ]
 
 EXTRA_JS_MODULES.loop += [
     'MozLoopAPI.jsm',
     'MozLoopPushHandler.jsm',
-    'MozLoopService.jsm',
     'MozLoopWorker.js',
 ]
+
+EXTRA_PP_JS_MODULES.loop += [
+    'MozLoopService.jsm',
+]
--- a/browser/components/loop/test/mochitest/browser.ini
+++ b/browser/components/loop/test/mochitest/browser.ini
@@ -1,10 +1,13 @@
 [DEFAULT]
 support-files =
     head.js
     loop_fxa.sjs
+    ../../../../base/content/test/general/browser_fxa_oauth.html
 
+[browser_fxa_login.js]
+skip-if = !debug
 [browser_loop_fxa_server.js]
 [browser_mozLoop_appVersionInfo.js]
 [browser_mozLoop_prefs.js]
 [browser_mozLoop_doNotDisturb.js]
 skip-if = buildapp == 'mulet'
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/browser_fxa_login.js
@@ -0,0 +1,92 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test FxA logins with Loop.
+ */
+
+"use strict";
+
+const BASE_URL = "http://mochi.test:8888/browser/browser/components/loop/test/mochitest/loop_fxa.sjs?";
+
+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");
+  });
+});
+
+add_task(function* checkOAuthParams() {
+  let params = {
+    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);
+  let client = yield MozLoopService.internal.promiseFxAOAuthClient();
+  for (let key of Object.keys(params)) {
+    ise(client.parameters[key], params[key], "Check " + key + " was passed to the OAuth client");
+  }
+});
+
+add_task(function* basicAuthorization() {
+  let result = yield MozLoopService.internal.promiseFxAOAuthAuthorization();
+  is(result.code, "code1", "Check code");
+  is(result.state, "state", "Check state");
+});
+
+add_task(function* sameOAuthClientForTwoCalls() {
+  MozLoopService.resetFxA();
+  let client1 = yield MozLoopService.internal.promiseFxAOAuthClient();
+  let client2 = yield MozLoopService.internal.promiseFxAOAuthClient();
+  ise(client1, client2, "The same client should be returned");
+});
+
+add_task(function* paramsInvalid() {
+  MozLoopService.resetFxA();
+  // Delete the params so an empty object is returned.
+  yield promiseDeletedOAuthParams(BASE_URL);
+  let result = null;
+  let loginPromise = MozLoopService.logInToFxA();
+  let caught = false;
+  yield loginPromise.catch(() => {
+    ok(true, "The login promise should be rejected due to invalid params");
+    caught = true;
+  });
+  ok(caught, "Should have caught the rejection");
+  is(result, null, "No token data should be returned");
+});
+
+add_task(function* params_nonJSON() {
+  MozLoopService.resetFxA();
+  Services.prefs.setCharPref("loop.server", "https://loop.invalid");
+  let result = null;
+  let loginPromise = MozLoopService.logInToFxA();
+  yield loginPromise.catch(() => {
+    ok(true, "The login promise should be rejected due to non-JSON params");
+  });
+  is(result, null, "No token data should be returned");
+  Services.prefs.setCharPref("loop.server", BASE_URL);
+});
+
+add_task(function* invalidState() {
+  MozLoopService.resetFxA();
+  let params = {
+    client_id: "client_id",
+    content_uri: BASE_URL + "/content",
+    oauth_uri: BASE_URL + "/oauth",
+    profile_uri: BASE_URL + "/profile",
+    state: "invalid_state",
+  };
+  yield promiseOAuthParamsSetup(BASE_URL, params);
+  let loginPromise = MozLoopService.logInToFxA();
+  yield loginPromise.catch((error) => {
+    ok(error, "The login promise should be rejected due to invalid state");
+  });
+});
--- a/browser/components/loop/test/mochitest/head.js
+++ b/browser/components/loop/test/mochitest/head.js
@@ -84,13 +84,13 @@ function promiseOAuthParamsSetup(baseURL
 }
 
 function promiseDeletedOAuthParams(baseURL) {
   let deferred = Promise.defer();
   let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
               createInstance(Ci.nsIXMLHttpRequest);
   xhr.open("DELETE", baseURL + "/setup_params", true);
   xhr.addEventListener("load", () => deferred.resolve(xhr));
-  xhr.addEventListener("error", error => deferred.reject(error));
+  xhr.addEventListener("error", deferred.reject);
   xhr.send();
 
   return deferred.promise;
 }
--- a/browser/components/loop/test/mochitest/loop_fxa.sjs
+++ b/browser/components/loop/test/mochitest/loop_fxa.sjs
@@ -10,23 +10,27 @@
 const REQUIRED_PARAMS = ["client_id", "content_uri", "oauth_uri", "profile_uri", "state"];
 
 Components.utils.import("resource://gre/modules/NetUtil.jsm");
 
 /**
  * Entry point for HTTP requests.
  */
 function handleRequest(request, response) {
-  switch (request.queryString) {
+  // Look at the query string but ignore past the encoded ? when deciding on the handler.
+  switch (request.queryString.replace(/%3F.*/,"")) {
     case "/setup_params":
       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;
   }
   response.setStatusLine(request.httpVersion, 404, "Not Found");
 }
 
 /**
@@ -85,16 +89,26 @@ function params(request, response) {
 
   // Save the result so we have the effective `state` value.
   setSharedState("/fxa-oauth/params", JSON.stringify(params));
   response.setHeader("Content-Type", "application/json; charset=utf-8", false);
   response.write(JSON.stringify(params, null, 2));
 }
 
 /**
+ * GET /oauth/authorization endpoint for the test params.
+ *
+ * Redirect to a test page that uses WebChannel to complete the web flow.
+ */
+function oauth_authorization(request, response) {
+  response.setStatusLine(request.httpVersion, 302, "Found");
+  response.setHeader("Location", "browser_fxa_oauth.html");
+}
+
+/**
  * POST /fxa-oauth/token
  *
  * Validate the state parameter with the server session state and if it matches, exchange the code
  * for an OAuth Token.
  * Parameters: code & state as JSON in the POST body.
  * Response: JSON containing an object of OAuth token information.
  */
 function token(request, response) {
--- a/services/fxaccounts/FxAccountsOAuthClient.jsm
+++ b/services/fxaccounts/FxAccountsOAuthClient.jsm
@@ -59,17 +59,19 @@ this.FxAccountsOAuthClient = function(op
   params.append("scope", this.parameters.scope || "");
   params.append("action", this.parameters.action || "signin");
   params.append("webChannelId", this._webChannelId);
 
 };
 
 this.FxAccountsOAuthClient.prototype = {
   /**
-   * Function that gets called once the OAuth flow is successfully complete.
+   * Function that gets called once the OAuth flow is complete.
+   * The callback will receive null as it's argument if there is a state mismatch or an object with
+   * code and state properties otherwise.
    */
   onComplete: null,
   /**
    * Configuration object that stores all OAuth parameters.
    */
   parameters: null,
   /**
    * WebChannel that is used to communicate with content page.
@@ -110,16 +112,17 @@ this.FxAccountsOAuthClient.prototype = {
 
   /**
    * Release all resources that are in use.
    */
   tearDown: function() {
     this.onComplete = null;
     this._complete = true;
     this._channel.stopListening();
+    this._channel = null;
   },
 
   /**
    * Configures WebChannel id and origin
    *
    * @private
    */
   _configureChannel: function() {
@@ -152,27 +155,34 @@ this.FxAccountsOAuthClient.prototype = {
     let listener = function (webChannelId, message, target) {
       if (message) {
         let command = message.command;
         let data = message.data;
 
         switch (command) {
           case "oauth_complete":
             // validate the state parameter and call onComplete
-            if (this.onComplete && data.code && this.parameters.state === data.state) {
-              log.debug("OAuth flow completed.");
-              this.onComplete({
+            let result = null;
+            if (this.parameters.state === data.state) {
+              result = {
                 code: data.code,
                 state: data.state
-              });
-              // onComplete will be called for this client only once
-              // calling onComplete again will result in a failure of the OAuth flow
-              this.tearDown();
+              };
+              log.debug("OAuth flow completed.");
+            } else {
+              log.debug("OAuth flow failed. State doesn't match");
             }
 
+            if (this.onComplete) {
+              this.onComplete(result);
+            }
+            // onComplete will be called for this client only once
+            // calling onComplete again will result in a failure of the OAuth flow
+            this.tearDown();
+
             // if the message asked to close the tab
             if (data.closeWindow && target && target.contentWindow) {
               target.contentWindow.close();
             }
             break;
         }
       }
     };