Backed out changeset ab4dc22fcd04 (bug 1330821) for OS X S Bustage
authorCarsten "Tomcat" Book <cbook@mozilla.com>
Fri, 20 Jan 2017 13:51:51 +0100
changeset 358370 d1f678120c11c025067e0cc5eca58d23e60e2bee
parent 358369 8ccb35efc96fd51be71e315ea0085a113235f7e6
child 358371 3d6d5a48deaf7c146b093ae129a0cb75c4334c0c
push id10621
push userjlund@mozilla.com
push dateMon, 23 Jan 2017 16:02:43 +0000
treeherdermozilla-aurora@dca7b42e6c67 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
bugs1330821
milestone53.0a1
backs outab4dc22fcd04354db1dc728754b497a83ca01e34
Backed out changeset ab4dc22fcd04 (bug 1330821) for OS X S Bustage
browser/base/content/test/general/browser.ini
browser/base/content/test/general/browser_fxa_oauth.html
browser/base/content/test/general/browser_fxa_oauth.js
browser/base/content/test/general/browser_fxa_oauth_with_keys.html
browser/installer/allowed-dupes.mn
services/fxaccounts/FxAccountsOAuthClient.jsm
services/fxaccounts/FxAccountsPush.js
services/fxaccounts/moz.build
services/fxaccounts/tests/xpcshell/test_oauth_client.js
services/fxaccounts/tests/xpcshell/test_push_service.js
services/fxaccounts/tests/xpcshell/xpcshell.ini
--- a/browser/base/content/test/general/browser.ini
+++ b/browser/base/content/test/general/browser.ini
@@ -6,16 +6,18 @@ support-files =
   app_bug575561.html
   app_subframe_bug575561.html
   aboutHome_content_script.js
   audio.ogg
   browser_bug479408_sample.html
   browser_bug678392-1.html
   browser_bug678392-2.html
   browser_bug970746.xhtml
+  browser_fxa_oauth.html
+  browser_fxa_oauth_with_keys.html
   browser_fxa_web_channel.html
   browser_registerProtocolHandler_notification.html
   browser_star_hsts.sjs
   browser_tab_dragdrop2_frame1.xul
   browser_web_channel.html
   browser_web_channel_iframe.html
   bug1262648_string_with_newlines.dtd
   bug592338.html
@@ -309,16 +311,17 @@ skip-if = true # browser_drag.js is disa
 [browser_findbarClose.js]
 [browser_focusonkeydown.js]
 [browser_fullscreen-window-open.js]
 tags = fullscreen
 skip-if = os == "linux" # Linux: Intermittent failures - bug 941575.
 [browser_fxaccounts.js]
 support-files = fxa_profile_handler.sjs
 [browser_fxa_migrate.js]
+[browser_fxa_oauth.js]
 [browser_fxa_web_channel.js]
 [browser_gestureSupport.js]
 skip-if = e10s # Bug 863514 - no gesture support.
 [browser_getshortcutoruri.js]
 [browser_hide_removing.js]
 [browser_homeDrop.js]
 [browser_identity_UI.js]
 [browser_insecureLoginForms.js]
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/browser_fxa_oauth.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>fxa_oauth_test</title>
+</head>
+<body>
+<script>
+  window.onload = function() {
+    var event = new window.CustomEvent("WebChannelMessageToChrome", {
+      // Note: This intentionally sends an object instead of a string, to ensure both work
+      // (see browser_fxa_oauth_with_keys.html for the other test)
+      detail: {
+        id: "oauth_client_id",
+        message: {
+          command: "oauth_complete",
+          data: {
+            state: "state",
+            code: "code1",
+            closeWindow: "signin",
+          },
+        },
+      },
+    });
+
+    window.dispatchEvent(event);
+  };
+</script>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/browser_fxa_oauth.js
@@ -0,0 +1,327 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+//
+// Whitelisting this test.
+// As part of bug 1077403, the leaking uncaught rejection should be fixed.
+//
+thisTestLeaksUncaughtRejectionsAndShouldBeFixed("TypeError: this.docShell is null");
+
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsOAuthClient",
+  "resource://gre/modules/FxAccountsOAuthClient.jsm");
+
+const HTTP_PATH = "http://example.com";
+const HTTP_ENDPOINT = "/browser/browser/base/content/test/general/browser_fxa_oauth.html";
+const HTTP_ENDPOINT_WITH_KEYS = "/browser/browser/base/content/test/general/browser_fxa_oauth_with_keys.html";
+
+var gTests = [
+  {
+    desc: "FxA OAuth - should open a new tab, complete OAuth flow",
+    run() {
+      return new Promise(function(resolve, reject) {
+        let tabOpened = false;
+        let properURL = "http://example.com/browser/browser/base/content/test/general/browser_fxa_oauth.html";
+        let queryStrings = [
+          "action=signin",
+          "client_id=client_id",
+          "scope=",
+          "state=state",
+          "webChannelId=oauth_client_id",
+        ];
+        queryStrings.sort();
+
+        waitForTab(function(tab) {
+          Assert.ok("Tab successfully opened");
+          Assert.ok(gBrowser.currentURI.spec.split("?")[0], properURL, "Check URL without params");
+          let actualURL = new URL(gBrowser.currentURI.spec);
+          let actualQueryStrings = actualURL.search.substring(1).split("&");
+          actualQueryStrings.sort();
+          Assert.equal(actualQueryStrings.length, queryStrings.length, "Check number of params");
+
+          for (let i = 0; i < queryStrings.length; i++) {
+            Assert.equal(actualQueryStrings[i], queryStrings[i], "Check parameter " + i);
+          }
+
+          tabOpened = true;
+        });
+
+        let client = new FxAccountsOAuthClient({
+          parameters: {
+            state: "state",
+            client_id: "client_id",
+            oauth_uri: HTTP_PATH,
+            content_uri: HTTP_PATH,
+          },
+          authorizationEndpoint: HTTP_ENDPOINT
+        });
+
+        client.onComplete = function(tokenData) {
+          Assert.ok(tabOpened);
+          Assert.equal(tokenData.code, "code1");
+          Assert.equal(tokenData.state, "state");
+          resolve();
+        };
+
+        client.onError = reject;
+
+        client.launchWebFlow();
+      });
+    }
+  },
+  {
+    desc: "FxA OAuth - should open a new tab, complete OAuth flow when forcing auth",
+    run() {
+      return new Promise(function(resolve, reject) {
+        let tabOpened = false;
+        let properURL = "http://example.com/browser/browser/base/content/test/general/browser_fxa_oauth.html";
+        let queryStrings = [
+          "action=force_auth",
+          "client_id=client_id",
+          "scope=",
+          "state=state",
+          "webChannelId=oauth_client_id",
+          "email=test%40invalid.com",
+        ];
+        queryStrings.sort();
+
+        waitForTab(function(tab) {
+          Assert.ok("Tab successfully opened");
+          Assert.ok(gBrowser.currentURI.spec.split("?")[0], properURL, "Check URL without params");
+
+          let actualURL = new URL(gBrowser.currentURI.spec);
+          let actualQueryStrings = actualURL.search.substring(1).split("&");
+          actualQueryStrings.sort();
+          Assert.equal(actualQueryStrings.length, queryStrings.length, "Check number of params");
+
+          for (let i = 0; i < queryStrings.length; i++) {
+            Assert.equal(actualQueryStrings[i], queryStrings[i], "Check parameter " + i);
+          }
+
+          tabOpened = true;
+        });
+
+        let client = new FxAccountsOAuthClient({
+          parameters: {
+            state: "state",
+            client_id: "client_id",
+            oauth_uri: HTTP_PATH,
+            content_uri: HTTP_PATH,
+            action: "force_auth",
+            email: "test@invalid.com"
+          },
+          authorizationEndpoint: HTTP_ENDPOINT
+        });
+
+        client.onComplete = function(tokenData) {
+          Assert.ok(tabOpened);
+          Assert.equal(tokenData.code, "code1");
+          Assert.equal(tokenData.state, "state");
+          resolve();
+        };
+
+        client.onError = reject;
+
+        client.launchWebFlow();
+      });
+    }
+  },
+  {
+    desc: "FxA OAuth - should receive an error when there's a state mismatch",
+    run() {
+      return new Promise(function(resolve, reject) {
+        let tabOpened = false;
+
+        waitForTab(function(tab) {
+          Assert.ok("Tab successfully opened");
+
+          // It should have passed in the expected non-matching state value.
+          let queryString = gBrowser.currentURI.spec.split("?")[1];
+          Assert.ok(queryString.indexOf("state=different-state") >= 0);
+
+          tabOpened = true;
+        });
+
+        let client = new FxAccountsOAuthClient({
+          parameters: {
+            state: "different-state",
+            client_id: "client_id",
+            oauth_uri: HTTP_PATH,
+            content_uri: HTTP_PATH,
+          },
+          authorizationEndpoint: HTTP_ENDPOINT
+        });
+
+        client.onComplete = reject;
+
+        client.onError = function(err) {
+          Assert.ok(tabOpened);
+          Assert.equal(err.message, "OAuth flow failed. State doesn't match");
+          resolve();
+        };
+
+        client.launchWebFlow();
+      });
+    }
+  },
+  {
+    desc: "FxA OAuth - should be able to request keys during OAuth flow",
+    run() {
+      return new Promise(function(resolve, reject) {
+        let tabOpened = false;
+
+        waitForTab(function(tab) {
+          Assert.ok("Tab successfully opened");
+
+          // It should have asked for keys.
+          let queryString = gBrowser.currentURI.spec.split("?")[1];
+          Assert.ok(queryString.indexOf("keys=true") >= 0);
+
+          tabOpened = true;
+        });
+
+        let client = new FxAccountsOAuthClient({
+          parameters: {
+            state: "state",
+            client_id: "client_id",
+            oauth_uri: HTTP_PATH,
+            content_uri: HTTP_PATH,
+            keys: true,
+          },
+          authorizationEndpoint: HTTP_ENDPOINT_WITH_KEYS
+        });
+
+        client.onComplete = function(tokenData, keys) {
+          Assert.ok(tabOpened);
+          Assert.equal(tokenData.code, "code1");
+          Assert.equal(tokenData.state, "state");
+          Assert.deepEqual(keys.kAr, {k: "kAr"});
+          Assert.deepEqual(keys.kBr, {k: "kBr"});
+          resolve();
+        };
+
+        client.onError = reject;
+
+        client.launchWebFlow();
+      });
+    }
+  },
+  {
+    desc: "FxA OAuth - should not receive keys if not explicitly requested",
+    run() {
+      return new Promise(function(resolve, reject) {
+        let tabOpened = false;
+
+        waitForTab(function(tab) {
+          Assert.ok("Tab successfully opened");
+
+          // It should not have asked for keys.
+          let queryString = gBrowser.currentURI.spec.split("?")[1];
+          Assert.ok(queryString.indexOf("keys=true") == -1);
+
+          tabOpened = true;
+        });
+
+        let client = new FxAccountsOAuthClient({
+          parameters: {
+            state: "state",
+            client_id: "client_id",
+            oauth_uri: HTTP_PATH,
+            content_uri: HTTP_PATH
+          },
+          // This endpoint will cause the completion message to contain keys.
+          authorizationEndpoint: HTTP_ENDPOINT_WITH_KEYS
+        });
+
+        client.onComplete = function(tokenData, keys) {
+          Assert.ok(tabOpened);
+          Assert.equal(tokenData.code, "code1");
+          Assert.equal(tokenData.state, "state");
+          Assert.strictEqual(keys, undefined);
+          resolve();
+        };
+
+        client.onError = reject;
+
+        client.launchWebFlow();
+      });
+    }
+  },
+  {
+    desc: "FxA OAuth - should receive an error if keys could not be obtained",
+    run() {
+      return new Promise(function(resolve, reject) {
+        let tabOpened = false;
+
+        waitForTab(function(tab) {
+          Assert.ok("Tab successfully opened");
+
+          // It should have asked for keys.
+          let queryString = gBrowser.currentURI.spec.split("?")[1];
+          Assert.ok(queryString.indexOf("keys=true") >= 0);
+
+          tabOpened = true;
+        });
+
+        let client = new FxAccountsOAuthClient({
+          parameters: {
+            state: "state",
+            client_id: "client_id",
+            oauth_uri: HTTP_PATH,
+            content_uri: HTTP_PATH,
+            keys: true,
+          },
+          // This endpoint will cause the completion message not to contain keys.
+          authorizationEndpoint: HTTP_ENDPOINT
+        });
+
+        client.onComplete = reject;
+
+        client.onError = function(err) {
+          Assert.ok(tabOpened);
+          Assert.equal(err.message, "OAuth flow failed. Keys were not returned");
+          resolve();
+        };
+
+        client.launchWebFlow();
+      });
+    }
+  }
+]; // gTests
+
+function waitForTab(aCallback) {
+  let container = gBrowser.tabContainer;
+  container.addEventListener("TabOpen", function tabOpener(event) {
+    container.removeEventListener("TabOpen", tabOpener);
+    gBrowser.addEventListener("load", function listener() {
+      gBrowser.removeEventListener("load", listener, true);
+      let tab = event.target;
+      aCallback(tab);
+    }, true);
+  });
+}
+
+function test() {
+  waitForExplicitFinish();
+
+  Task.spawn(function* () {
+    const webchannelWhitelistPref = "webchannel.allowObject.urlWhitelist";
+    let origWhitelist = Services.prefs.getCharPref(webchannelWhitelistPref);
+    let newWhitelist = origWhitelist + " http://example.com";
+    Services.prefs.setCharPref(webchannelWhitelistPref, newWhitelist);
+    try {
+      for (let testCase of gTests) {
+        info("Running: " + testCase.desc);
+        yield testCase.run();
+      }
+    } finally {
+      Services.prefs.clearUserPref(webchannelWhitelistPref);
+    }
+  }).then(finish, ex => {
+    Assert.ok(false, "Unexpected Exception: " + ex);
+    finish();
+  });
+}
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/browser_fxa_oauth_with_keys.html
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>fxa_oauth_test</title>
+</head>
+<body>
+<script>
+  window.onload = function() {
+    var event = new window.CustomEvent("WebChannelMessageToChrome", {
+      // Note: This intentionally sends a string instead of an object, to ensure both work
+      // (see browser_fxa_oauth.html for the other test)
+      detail: JSON.stringify({
+        id: "oauth_client_id",
+        message: {
+          command: "oauth_complete",
+          data: {
+            state: "state",
+            code: "code1",
+            closeWindow: "signin",
+            // Keys normally contain more information, but this is enough
+            // to keep Loop's tests happy.
+            keys: { kAr: { k: "kAr" }, kBr: { k: "kBr" }},
+          },
+        },
+      }),
+    });
+
+    window.dispatchEvent(event);
+  };
+</script>
+</body>
+</html>
--- a/browser/installer/allowed-dupes.mn
+++ b/browser/installer/allowed-dupes.mn
@@ -195,21 +195,23 @@ chrome/toolkit/skin/classic/global/wizar
 chrome/toolkit/skin/classic/mozapps/downloads/buttons.png
 chrome/toolkit/skin/classic/mozapps/downloads/downloadButtons.png
 chrome/toolkit/skin/classic/mozapps/extensions/category-dictionaries.png
 chrome/toolkit/skin/classic/mozapps/extensions/category-experiments.png
 chrome/toolkit/skin/classic/mozapps/extensions/dictionaryGeneric.png
 chrome/toolkit/skin/classic/mozapps/extensions/experimentGeneric.png
 chrome/toolkit/skin/classic/mozapps/update/buttons.png
 chrome/toolkit/skin/classic/mozapps/update/downloadButtons.png
+components/FxAccountsPush.js
 crashreporter.app/Contents/Resources/English.lproj/MainMenu.nib/classes.nib
 crashreporter.app/Contents/Resources/English.lproj/MainMenuRTL.nib/classes.nib
 # firefox/firefox-bin is bug 658850
 firefox
 firefox-bin
+modules/FxAccountsPush.js
 modules/commonjs/index.js
 modules/commonjs/sdk/ui/button/view/events.js
 modules/commonjs/sdk/ui/state/events.js
 plugin-container.app/Contents/PkgInfo
 res/table-remove-column-active.gif
 res/table-remove-column-hover.gif
 res/table-remove-column.gif
 res/table-remove-row-active.gif
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/FxAccountsOAuthClient.jsm
@@ -0,0 +1,269 @@
+/* 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/. */
+
+/**
+ * Firefox Accounts OAuth browser login helper.
+ * Uses the WebChannel component to receive OAuth messages and complete login flows.
+ */
+
+this.EXPORTED_SYMBOLS = ["FxAccountsOAuthClient"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+XPCOMUtils.defineLazyModuleGetter(this, "WebChannel",
+                                  "resource://gre/modules/WebChannel.jsm");
+Cu.importGlobalProperties(["URL"]);
+
+/**
+ * Create a new FxAccountsOAuthClient for browser some service.
+ *
+ * @param {Object} options Options
+ *   @param {Object} options.parameters
+ *   Opaque alphanumeric token to be included in verification links
+ *     @param {String} options.parameters.client_id
+ *     OAuth id returned from client registration
+ *     @param {String} options.parameters.state
+ *     A value that will be returned to the client as-is upon redirection
+ *     @param {String} options.parameters.oauth_uri
+ *     The FxA OAuth server uri
+ *     @param {String} options.parameters.content_uri
+ *     The FxA Content server uri
+ *     @param {String} [options.parameters.scope]
+ *     Optional. A colon-separated list of scopes that the user has authorized
+ *     @param {String} [options.parameters.action]
+ *     Optional. If provided, should be either signup, signin or force_auth.
+ *     @param {String} [options.parameters.email]
+ *     Optional. Required if options.paramters.action is 'force_auth'.
+ *     @param {Boolean} [options.parameters.keys]
+ *     Optional. If true then relier-specific encryption keys will be
+ *     available in the second argument to onComplete.
+ *   @param [authorizationEndpoint] {String}
+ *   Optional authorization endpoint for the OAuth server
+ * @constructor
+ */
+this.FxAccountsOAuthClient = function(options) {
+  this._validateOptions(options);
+  this.parameters = options.parameters;
+  this._configureChannel();
+
+  let authorizationEndpoint = options.authorizationEndpoint || "/authorization";
+
+  try {
+    this._fxaOAuthStartUrl = new URL(this.parameters.oauth_uri + authorizationEndpoint + "?");
+  } catch (e) {
+    throw new Error("Invalid OAuth Url");
+  }
+
+  let params = this._fxaOAuthStartUrl.searchParams;
+  params.append("client_id", this.parameters.client_id);
+  params.append("state", this.parameters.state);
+  params.append("scope", this.parameters.scope || "");
+  params.append("action", this.parameters.action || "signin");
+  params.append("webChannelId", this._webChannelId);
+  if (this.parameters.keys) {
+    params.append("keys", "true");
+  }
+  // Only append if we actually have a value.
+  if (this.parameters.email) {
+    params.append("email", this.parameters.email);
+  }
+};
+
+this.FxAccountsOAuthClient.prototype = {
+  /**
+   * Function that gets called once the OAuth flow is complete.
+   * The callback will receive an object with code and state properties.
+   * If the keys parameter was specified and true, the callback will receive
+   * a second argument with kAr and kBr properties.
+   */
+  onComplete: null,
+  /**
+   * Function that gets called if there is an error during the OAuth flow,
+   * for example due to a state mismatch.
+   * The callback will receive an Error object as its argument.
+   */
+  onError: null,
+  /**
+   * Configuration object that stores all OAuth parameters.
+   */
+  parameters: null,
+  /**
+   * WebChannel that is used to communicate with content page.
+   */
+  _channel: null,
+  /**
+   * Boolean to indicate if this client has completed an OAuth flow.
+   */
+  _complete: false,
+  /**
+   * The url that opens the Firefox Accounts OAuth flow.
+   */
+  _fxaOAuthStartUrl: null,
+  /**
+   * WebChannel id.
+   */
+  _webChannelId: null,
+  /**
+   * WebChannel origin, used to validate origin of messages.
+   */
+  _webChannelOrigin: null,
+  /**
+   * Opens a tab at "this._fxaOAuthStartUrl".
+   * Registers a WebChannel listener and sets up a callback if needed.
+   */
+  launchWebFlow() {
+    if (!this._channelCallback) {
+      this._registerChannel();
+    }
+
+    if (this._complete) {
+      throw new Error("This client already completed the OAuth flow");
+    } else {
+      let opener = Services.wm.getMostRecentWindow("navigator:browser").gBrowser;
+      opener.selectedTab = opener.addTab(this._fxaOAuthStartUrl.href);
+    }
+  },
+
+  /**
+   * Release all resources that are in use.
+   */
+  tearDown() {
+    this.onComplete = null;
+    this.onError = null;
+    this._complete = true;
+    this._channel.stopListening();
+    this._channel = null;
+  },
+
+  /**
+   * Configures WebChannel id and origin
+   *
+   * @private
+   */
+  _configureChannel() {
+    this._webChannelId = "oauth_" + this.parameters.client_id;
+
+    // if this.parameters.content_uri is present but not a valid URI, then this will throw an error.
+    try {
+      this._webChannelOrigin = Services.io.newURI(this.parameters.content_uri);
+    } catch (e) {
+      throw e;
+    }
+  },
+
+  /**
+   * Create a new channel with the WebChannelBroker, setup a callback listener
+   * @private
+   */
+  _registerChannel() {
+    /**
+     * Processes messages that are called back from the FxAccountsChannel
+     *
+     * @param webChannelId {String}
+     *        Command webChannelId
+     * @param message {Object}
+     *        Command message
+     * @param sendingContext {Object}
+     *        Channel message event sendingContext
+     * @private
+     */
+    let listener = function(webChannelId, message, sendingContext) {
+      if (message) {
+        let command = message.command;
+        let data = message.data;
+        let target = sendingContext && sendingContext.browser;
+
+        switch (command) {
+          case "oauth_complete":
+            // validate the returned state and call onComplete or onError
+            let result = null;
+            let err = null;
+
+            if (this.parameters.state !== data.state) {
+              err = new Error("OAuth flow failed. State doesn't match");
+            } else if (this.parameters.keys && !data.keys) {
+              err = new Error("OAuth flow failed. Keys were not returned");
+            } else {
+              result = {
+                code: data.code,
+                state: data.state
+              };
+            }
+
+            // if the message asked to close the tab
+            if (data.closeWindow && target) {
+              // for e10s reasons the best way is to use the TabBrowser to close the tab.
+              let tabbrowser = target.getTabBrowser();
+
+              if (tabbrowser) {
+                let tab = tabbrowser.getTabForBrowser(target);
+
+                if (tab) {
+                  tabbrowser.removeTab(tab);
+                  log.debug("OAuth flow closed the tab.");
+                } else {
+                  log.debug("OAuth flow failed to close the tab. Tab not found in TabBrowser.");
+                }
+              } else {
+                log.debug("OAuth flow failed to close the tab. TabBrowser not found.");
+              }
+            }
+
+            if (err) {
+              log.debug(err.message);
+              if (this.onError) {
+                this.onError(err);
+              }
+            } else {
+              log.debug("OAuth flow completed.");
+              if (this.onComplete) {
+                if (this.parameters.keys) {
+                  this.onComplete(result, data.keys);
+                } else {
+                  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();
+            break;
+        }
+      }
+    };
+
+    this._channelCallback = listener.bind(this);
+    this._channel = new WebChannel(this._webChannelId, this._webChannelOrigin);
+    this._channel.listen(this._channelCallback);
+    log.debug("Channel registered: " + this._webChannelId + " with origin " + this._webChannelOrigin.prePath);
+  },
+
+  /**
+   * Validates the required FxA OAuth parameters
+   *
+   * @param options {Object}
+   *        OAuth client options
+   * @private
+   */
+  _validateOptions(options) {
+    if (!options || !options.parameters) {
+      throw new Error("Missing 'parameters' configuration option");
+    }
+
+    ["oauth_uri", "client_id", "content_uri", "state"].forEach(option => {
+      if (!options.parameters[option]) {
+        throw new Error("Missing 'parameters." + option + "' parameter");
+      }
+    });
+
+    if (options.parameters.action == "force_auth" && !options.parameters.email) {
+      throw new Error("parameters.email is required for action 'force_auth'");
+    }
+  },
+};
--- a/services/fxaccounts/FxAccountsPush.js
+++ b/services/fxaccounts/FxAccountsPush.js
@@ -233,8 +233,11 @@ FxAccountsPushService.prototype = {
         });
     });
   },
 };
 
 // Service registration below registers with FxAccountsComponents.manifest
 const components = [FxAccountsPushService];
 this.NSGetFactory = XPCOMUtils.generateNSGetFactory(components);
+
+// The following registration below helps with testing this service.
+this.EXPORTED_SYMBOLS = ["FxAccountsPushService"];
--- a/services/fxaccounts/moz.build
+++ b/services/fxaccounts/moz.build
@@ -18,18 +18,20 @@ EXTRA_COMPONENTS += [
 ]
 
 EXTRA_JS_MODULES += [
   'Credentials.jsm',
   'FxAccounts.jsm',
   'FxAccountsClient.jsm',
   'FxAccountsCommon.js',
   'FxAccountsConfig.jsm',
+  'FxAccountsOAuthClient.jsm',
   'FxAccountsOAuthGrantClient.jsm',
   'FxAccountsProfile.jsm',
   'FxAccountsProfileClient.jsm',
+  'FxAccountsPush.js',
   'FxAccountsStorage.jsm',
   'FxAccountsWebChannel.jsm',
 ]
 
 # For now, we will only be using the FxA manager in B2G.
 if CONFIG['MOZ_B2G']:
   EXTRA_JS_MODULES += ['FxAccountsManager.jsm']
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_client.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccountsOAuthClient.jsm");
+
+function run_test() {
+  validationHelper(undefined,
+  "Error: Missing 'parameters' configuration option");
+
+  validationHelper({},
+  "Error: Missing 'parameters' configuration option");
+
+  validationHelper({ parameters: {} },
+  "Error: Missing 'parameters.oauth_uri' parameter");
+
+  validationHelper({ parameters: {
+    oauth_uri: "http://oauth.test/v1"
+  }},
+  "Error: Missing 'parameters.client_id' parameter");
+
+  validationHelper({ parameters: {
+    oauth_uri: "http://oauth.test/v1",
+    client_id: "client_id"
+  }},
+  "Error: Missing 'parameters.content_uri' parameter");
+
+  validationHelper({ parameters: {
+    oauth_uri: "http://oauth.test/v1",
+    client_id: "client_id",
+    content_uri: "http://content.test"
+  }},
+  "Error: Missing 'parameters.state' parameter");
+
+  validationHelper({ parameters: {
+    oauth_uri: "http://oauth.test/v1",
+    client_id: "client_id",
+    content_uri: "http://content.test",
+    state: "complete",
+    action: "force_auth"
+  }},
+  "Error: parameters.email is required for action 'force_auth'");
+
+  run_next_test();
+}
+
+function validationHelper(params, expected) {
+  try {
+    new FxAccountsOAuthClient(params);
+  } catch (e) {
+    return do_check_eq(e.toString(), expected);
+  }
+  throw new Error("Validation helper error");
+}
--- a/services/fxaccounts/tests/xpcshell/test_push_service.js
+++ b/services/fxaccounts/tests/xpcshell/test_push_service.js
@@ -4,22 +4,19 @@
 "use strict";
 
 // Tests for the FxA push service.
 
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsPush.js");
 Cu.import("resource://gre/modules/Log.jsm");
 
-let scope = {};
-Services.scriptloader.loadSubScript("resource://gre/components/FxAccountsPush.js", scope);
-const FxAccountsPushService = scope.FxAccountsPushService;
-
 XPCOMUtils.defineLazyServiceGetter(this, "pushService",
   "@mozilla.org/push/Service;1", "nsIPushService");
 
 initTestLogging("Trace");
 log.level = Log.Level.Trace;
 
 const MOCK_ENDPOINT = "http://mochi.test:8888";
 
--- a/services/fxaccounts/tests/xpcshell/xpcshell.ini
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini
@@ -5,16 +5,17 @@ support-files =
   !/services/common/tests/unit/head_helpers.js
   !/services/common/tests/unit/head_http.js
 
 [test_accounts.js]
 [test_accounts_device_registration.js]
 [test_client.js]
 [test_credentials.js]
 [test_loginmgr_storage.js]
+[test_oauth_client.js]
 [test_oauth_grant_client.js]
 [test_oauth_grant_client_server.js]
 [test_oauth_tokens.js]
 [test_oauth_token_storage.js]
 [test_profile_client.js]
 [test_push_service.js]
 [test_web_channel.js]
 [test_profile.js]