Bug 1022064 - Add WebChannel Communication API and FxAccountsOAuthClient API to facilitate Firefox Accounts OAuth authentication. r=MattN, sr=gavin
authorVlad Filippov <vlad.filippov@gmail.com>
Fri, 01 Aug 2014 01:42:00 -0400
changeset 219024 bf5985c067af2f69fef2b173d85b7e01b99ccf42
parent 218957 b1abb84787d1d5bb92b8fcac4082ee41fb8c0870
child 219025 f08fb8275e5be58e7b84d9f90a01249055a3ae6c
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)
reviewersMattN, gavin
bugs1022064
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 1022064 - Add WebChannel Communication API and FxAccountsOAuthClient API to facilitate Firefox Accounts OAuth authentication. r=MattN, sr=gavin
browser/base/content/content.js
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_web_channel.html
browser/base/content/test/general/browser_web_channel.js
services/fxaccounts/FxAccountsOAuthClient.jsm
services/fxaccounts/moz.build
services/fxaccounts/tests/xpcshell/test_oauth_client.js
services/fxaccounts/tests/xpcshell/xpcshell.ini
toolkit/modules/WebChannel.jsm
toolkit/modules/moz.build
toolkit/modules/tests/xpcshell/test_web_channel.js
toolkit/modules/tests/xpcshell/test_web_channel_broker.js
toolkit/modules/tests/xpcshell/xpcshell.ini
--- a/browser/base/content/content.js
+++ b/browser/base/content/content.js
@@ -211,16 +211,43 @@ let AboutHomeListener = {
         sendAsyncMessage("AboutHome:Settings");
         break;
     }
   },
 };
 AboutHomeListener.init(this);
 
 
+// An event listener for custom "WebChannelMessageToChrome" events on pages
+addEventListener("WebChannelMessageToChrome", function (e) {
+  // if target is window then we want the document principal, otherwise fallback to target itself.
+  let principal = e.target.nodePrincipal ? e.target.nodePrincipal : e.target.document.nodePrincipal;
+
+  if (e.detail) {
+    sendAsyncMessage("WebChannelMessageToChrome", e.detail, null, principal);
+  } else  {
+    Cu.reportError("WebChannel message failed. No message detail.");
+  }
+}, true, true);
+
+// Add message listener for "WebChannelMessageToContent" messages from chrome scripts
+addMessageListener("WebChannelMessageToContent", function (e) {
+  if (e.data) {
+    content.dispatchEvent(new content.CustomEvent("WebChannelMessageToContent", {
+      detail: Cu.cloneInto({
+        id: e.data.id,
+        message: e.data.message,
+      }, content),
+    }));
+  } else {
+    Cu.reportError("WebChannel message failed. No message data.");
+  }
+});
+
+
 let ContentSearchMediator = {
 
   whitelist: new Set([
     "about:newtab",
   ]),
 
   init: function (chromeGlobal) {
     chromeGlobal.addEventListener("ContentSearchClient", this, true, true);
--- a/browser/base/content/test/general/browser.ini
+++ b/browser/base/content/test/general/browser.ini
@@ -6,19 +6,21 @@ support-files =
   app_bug575561.html
   app_subframe_bug575561.html
   authenticate.sjs
   aboutHome_content_script.js
   browser_bug479408_sample.html
   browser_bug678392-1.html
   browser_bug678392-2.html
   browser_bug970746.xhtml
+  browser_fxa_oauth.html
   browser_registerProtocolHandler_notification.html
   browser_star_hsts.sjs
   browser_tab_dragdrop2_frame1.xul
+  browser_web_channel.html
   bug564387.html
   bug564387_video1.ogv
   bug564387_video1.ogv^headers^
   bug592338.html
   bug792517-2.html
   bug792517.html
   bug792517.sjs
   bug839103.css
@@ -290,16 +292,17 @@ skip-if = e10s # Bug 973001 - appears us
 skip-if = e10s # Bug 918663 - DOMLinkAdded events don't make their way to chrome
 [browser_duplicateIDs.js]
 [browser_drag.js]
 skip-if = true # browser_drag.js is disabled, as it needs to be updated for the new behavior from bug 320638.
 [browser_favicon_change.js]
 [browser_findbarClose.js]
 skip-if = e10s # Bug ?????? - test directly manipulates content (tries to grab an iframe directly from content)
 [browser_fullscreen-window-open.js]
+[browser_fxa_oauth.js]
 skip-if = buildapp == 'mulet' || e10s || os == "linux" # Bug 933103 - mochitest's EventUtils.synthesizeMouse functions not e10s friendly. Linux: Intermittent failures - bug 941575.
 [browser_gestureSupport.js]
 skip-if = e10s # Bug 863514 - no gesture support.
 [browser_getshortcutoruri.js]
 [browser_hide_removing.js]
 [browser_homeDrop.js]
 skip-if = buildapp == 'mulet'
 [browser_identity_UI.js]
@@ -423,16 +426,17 @@ skip-if = e10s # Bug ?????? - test direc
 skip-if = e10s # Bug 921905 - pinTab/unpinTab fail in e10s
 [browser_visibleTabs_bookmarkAllPages.js]
 skip-if = true # Bug 1005420 - fails intermittently. also with e10s enabled: bizarre problem with hidden tab having _mouseenter called, via _setPositionalAttributes, and tab not being found resulting in 'candidate is undefined'
 [browser_visibleTabs_bookmarkAllTabs.js]
 skip-if = e10s # Bug 921905 - pinTab/unpinTab fail in e10s
 [browser_visibleTabs_contextMenu.js]
 skip-if = e10s # Bug 921905 - pinTab/unpinTab fail in e10s
 [browser_visibleTabs_tabPreview.js]
+[browser_web_channel.js]
 skip-if = (os == "win" && !debug) || e10s # Bug 1007418 / Bug 698371 - thumbnail captures need e10s love (tabPreviews_capture fails with Argument 1 of CanvasRenderingContext2D.drawWindow does not implement interface Window.)
 [browser_windowopen_reflows.js]
 skip-if = buildapp == 'mulet'
 [browser_wyciwyg_urlbarCopying.js]
 skip-if = e10s # Bug ?????? - test directly manipulates content (content.document.getElementById)
 [browser_zbug569342.js]
 skip-if = e10s # Bug 516755 - SessionStore disabled for e10s
 [browser_registerProtocolHandler_notification.js]
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/browser_fxa_oauth.html
@@ -0,0 +1,28 @@
+<!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", {
+      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,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+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";
+
+let gTests = [
+  {
+    desc: "FxA OAuth - should open a new tab, complete OAuth flow",
+    run: function* () {
+      return new Promise(function(resolve, reject) {
+        let tabOpened = false;
+        let properUrl = "http://example.com/browser/browser/base/content/test/general/browser_fxa_oauth.html?" +
+          "webChannelId=oauth_client_id&scope=&client_id=client_id&action=signin&state=state";
+        waitForTab(function (tab) {
+          Assert.ok("Tab successfully opened");
+          Assert.equal(gBrowser.currentURI.spec, properUrl);
+          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.launchWebFlow();
+      });
+    }
+  }
+]; // gTests
+
+function waitForTab(aCallback) {
+  let container = gBrowser.tabContainer;
+  container.addEventListener("TabOpen", function tabOpener(event) {
+    container.removeEventListener("TabOpen", tabOpener, false);
+    gBrowser.addEventListener("load", function listener() {
+      gBrowser.removeEventListener("load", listener, true);
+      let tab = event.target;
+      aCallback(tab);
+    }, true);
+  }, false);
+}
+
+function test() {
+  waitForExplicitFinish();
+
+  Task.spawn(function () {
+    for (let test of gTests) {
+      info("Running: " + test.desc);
+      yield test.run();
+    }
+  }).then(finish, ex => {
+    Assert.ok(false, "Unexpected Exception: " + ex);
+    finish();
+  });
+}
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/browser_web_channel.html
@@ -0,0 +1,89 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>web_channel_test</title>
+</head>
+<body>
+<script>
+  window.onload = function() {
+    var testName = window.location.search.replace(/^\?/, "");
+
+    switch(testName) {
+      case "generic":
+        test_generic();
+        break;
+      case "twoway":
+        test_twoWay();
+        break;
+      case "multichannel":
+        test_multichannel();
+        break;
+    }
+  };
+
+  function test_generic() {
+    var event = new window.CustomEvent("WebChannelMessageToChrome", {
+      detail: {
+        id: "generic",
+        message: {
+          something: {
+            nested: "hello",
+          },
+        }
+      }
+    });
+
+    window.dispatchEvent(event);
+  }
+
+  function test_twoWay() {
+    var firstMessage = new window.CustomEvent("WebChannelMessageToChrome", {
+      detail: {
+        id: "twoway",
+        message: {
+          command: "one",
+        },
+      }
+    });
+
+    window.addEventListener("WebChannelMessageToContent", function(e) {
+      var secondMessage = new window.CustomEvent("WebChannelMessageToChrome", {
+        detail: {
+          id: "twoway",
+          message: {
+            command: "two",
+            detail: e.detail.message,
+          },
+        },
+      });
+
+      if (!e.detail.message.error) {
+        window.dispatchEvent(secondMessage);
+      }
+    }, true);
+
+    window.dispatchEvent(firstMessage);
+  }
+
+  function test_multichannel() {
+    var event1 = new window.CustomEvent("WebChannelMessageToChrome", {
+      detail: {
+        id: "wrongchannel",
+        message: {},
+      }
+    });
+
+    var event2 = new window.CustomEvent("WebChannelMessageToChrome", {
+      detail: {
+        id: "multichannel",
+        message: {},
+      }
+    });
+
+    window.dispatchEvent(event1);
+    window.dispatchEvent(event2);
+  }
+</script>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/browser_web_channel.js
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "WebChannel",
+  "resource://gre/modules/WebChannel.jsm");
+
+const HTTP_PATH = "http://example.com";
+const HTTP_ENDPOINT = "/browser/browser/base/content/test/general/browser_web_channel.html";
+
+let gTests = [
+  {
+    desc: "WebChannel generic message",
+    run: function* () {
+      return new Promise(function(resolve, reject) {
+        let tab;
+        let channel = new WebChannel("generic", Services.io.newURI(HTTP_PATH, null, null));
+        channel.listen(function (id, message, target) {
+          is(id, "generic");
+          is(message.something.nested, "hello");
+          channel.stopListening();
+          gBrowser.removeTab(tab);
+          resolve();
+        });
+
+        tab = gBrowser.addTab(HTTP_PATH + HTTP_ENDPOINT + "?generic");
+      });
+    }
+  },
+  {
+    desc: "WebChannel two way communication",
+    run: function* () {
+      return new Promise(function(resolve, reject) {
+        let tab;
+        let channel = new WebChannel("twoway", Services.io.newURI(HTTP_PATH, null, null));
+
+        channel.listen(function (id, message, sender) {
+          is(id, "twoway");
+          ok(message.command);
+
+          if (message.command === "one") {
+            channel.send({ data: { nested: true } }, sender);
+          }
+
+          if (message.command === "two") {
+            is(message.detail.data.nested, true);
+            channel.stopListening();
+            gBrowser.removeTab(tab);
+            resolve();
+          }
+        });
+
+        tab = gBrowser.addTab(HTTP_PATH + HTTP_ENDPOINT + "?twoway");
+      });
+    }
+  },
+  {
+    desc: "WebChannel multichannel",
+    run: function* () {
+      return new Promise(function(resolve, reject) {
+        let tab;
+        let channel = new WebChannel("multichannel", Services.io.newURI(HTTP_PATH, null, null));
+
+        channel.listen(function (id, message, sender) {
+          is(id, "multichannel");
+          gBrowser.removeTab(tab);
+          resolve();
+        });
+
+        tab = gBrowser.addTab(HTTP_PATH + HTTP_ENDPOINT + "?multichannel");
+      });
+    }
+  }
+]; // gTests
+
+function test() {
+  waitForExplicitFinish();
+
+  Task.spawn(function () {
+    for (let test of gTests) {
+      info("Running: " + test.desc);
+      yield test.run();
+    }
+  }).then(finish, ex => {
+    ok(false, "Unexpected Exception: " + ex);
+    finish();
+  });
+}
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/FxAccountsOAuthClient.jsm
@@ -0,0 +1,204 @@
+/* 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/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 or signin.
+ *   @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);
+
+};
+
+this.FxAccountsOAuthClient.prototype = {
+  /**
+   * Function that gets called once the OAuth flow is successfully complete.
+   */
+  onComplete: 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: function () {
+    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: function() {
+    this.onComplete = null;
+    this._complete = true;
+    this._channel.stopListening();
+  },
+
+  /**
+   * Configures WebChannel id and origin
+   *
+   * @private
+   */
+  _configureChannel: function() {
+    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, null, null);
+    } catch (e) {
+      throw e;
+    }
+  },
+
+  /**
+   * Create a new channel with the WebChannelBroker, setup a callback listener
+   * @private
+   */
+  _registerChannel: function() {
+    /**
+     * Processes messages that are called back from the FxAccountsChannel
+     *
+     * @param webChannelId {String}
+     *        Command webChannelId
+     * @param message {Object}
+     *        Command message
+     * @param target {EventTarget}
+     *        Channel message event target
+     * @private
+     */
+    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({
+                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();
+            }
+
+            // if the message asked to close the tab
+            if (data.closeWindow && target && target.contentWindow) {
+              target.contentWindow.close();
+            }
+            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: function (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");
+      }
+    });
+  },
+};
--- a/services/fxaccounts/moz.build
+++ b/services/fxaccounts/moz.build
@@ -7,14 +7,15 @@
 DIRS += ['interfaces']
 
 TEST_DIRS += ['tests']
 
 EXTRA_JS_MODULES += [
   'Credentials.jsm',
   'FxAccounts.jsm',
   'FxAccountsClient.jsm',
-  'FxAccountsCommon.js'
+  'FxAccountsCommon.js',
+  'FxAccountsOAuthClient.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,46 @@
+/* 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");
+
+  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/xpcshell.ini
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini
@@ -3,8 +3,9 @@ head = head.js ../../../common/tests/uni
 tail =
 
 [test_accounts.js]
 [test_client.js]
 [test_credentials.js]
 [test_manager.js]
 run-if = appname == 'b2g'
 reason = FxAccountsManager is only available for B2G for now
+[test_oauth_client.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/WebChannel.jsm
@@ -0,0 +1,249 @@
+/* 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/. */
+
+/**
+ * WebChannel is an abstraction that uses the Message Manager and Custom Events
+ * to create a two-way communication channel between chrome and content code.
+ */
+
+this.EXPORTED_SYMBOLS = ["WebChannel", "WebChannelBroker"];
+
+const ERRNO_UNKNOWN_ERROR              = 999;
+const ERROR_UNKNOWN                    = "UNKNOWN_ERROR";
+
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+
+/**
+ * WebChannelBroker is a global object that helps manage WebChannel objects.
+ * This object handles channel registration, origin validation and message multiplexing.
+ */
+
+let WebChannelBroker = Object.create({
+  /**
+   * Register a new channel that callbacks messages
+   * based on proper origin and channel name
+   *
+   * @param channel {WebChannel}
+   */
+  registerChannel: function (channel) {
+    if (!this._channelMap.has(channel)) {
+      this._channelMap.set(channel);
+    } else {
+      Cu.reportError("Failed to register the channel. Channel already exists.");
+    }
+
+    // attach the global message listener if needed
+    if (!this._messageListenerAttached) {
+      this._messageListenerAttached = true;
+      this._manager.addMessageListener("WebChannelMessageToChrome", this._listener.bind(this));
+    }
+  },
+
+  /**
+   * Unregister a channel
+   *
+   * @param channelToRemove {WebChannel}
+   *        WebChannel to remove from the channel map
+   *
+   * Removes the specified channel from the channel map
+   */
+  unregisterChannel: function (channelToRemove) {
+    if (!this._channelMap.delete(channelToRemove)) {
+      Cu.reportError("Failed to unregister the channel. Channel not found.");
+    }
+  },
+
+  /**
+   * @param event {Event}
+   *        Message Manager event
+   * @private
+   */
+  _listener: function (event) {
+    let data = event.data;
+    let sender = event.target;
+
+    if (data && data.id) {
+      if (!event.principal) {
+        this._sendErrorEventToContent(data.id, sender, "Message principal missing");
+      } else {
+        let validChannelFound = false;
+        data.message = data.message || {};
+
+        for (var channel of this._channelMap.keys()) {
+          if (channel.id === data.id &&
+            channel.origin.prePath === event.principal.origin) {
+            validChannelFound = true;
+            channel.deliver(data, sender);
+          }
+        }
+
+        // if no valid origins send an event that there is no such valid channel
+        if (!validChannelFound) {
+          this._sendErrorEventToContent(data.id, sender, "No Such Channel");
+        }
+      }
+    } else {
+      Cu.reportError("WebChannel channel id missing");
+    }
+  },
+  /**
+   * The global message manager operates on every <browser>
+   */
+  _manager: Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager),
+  /**
+   * Boolean used to detect if the global message manager event is already attached
+   */
+  _messageListenerAttached: false,
+  /**
+   * Object to store pairs of message origins and callback functions
+   */
+  _channelMap: new Map(),
+  /**
+   *
+   * @param id {String}
+   *        The WebChannel id to include in the message
+   * @param sender {EventTarget}
+   *        EventTarget with a "messageManager" that will send be used to send the message
+   * @param [errorMsg] {String}
+   *        Error message
+   * @private
+   */
+  _sendErrorEventToContent: function (id, sender, errorMsg) {
+    errorMsg = errorMsg || "Web Channel Broker error";
+
+    if (sender.messageManager) {
+      sender.messageManager.sendAsyncMessage("WebChannelMessageToContent", {
+        id: id,
+        error: errorMsg,
+      }, sender);
+    }
+    Cu.reportError(id.toString() + " error message. " + errorMsg);
+  },
+});
+
+
+/**
+ * Creates a new WebChannel that listens and sends messages over some channel id
+ *
+ * @param id {String}
+ *        WebChannel id
+ * @param origin {nsIURI}
+ *        Valid origin that should be part of requests for this channel
+ * @constructor
+ */
+this.WebChannel = function(id, origin) {
+  if (!id || !origin) {
+    throw new Error("WebChannel id and origin are required.");
+  }
+
+  this.id = id;
+  this.origin = origin;
+};
+
+this.WebChannel.prototype = {
+
+  /**
+   * WebChannel id
+   */
+  id: null,
+
+  /**
+   * WebChannel origin
+   */
+  origin: null,
+
+  /**
+   * WebChannelBroker that manages WebChannels
+   */
+  _broker: WebChannelBroker,
+
+  /**
+   * Callback that will be called with the contents of an incoming message
+   */
+  _deliverCallback: null,
+
+  /**
+   * Registers the callback for messages on this channel
+   * Registers the channel itself with the WebChannelBroker
+   *
+   * @param callback {Function}
+   *        Callback that will be called when there is a message
+   *        @param {String} id
+   *        The WebChannel id that was used for this message
+   *        @param {Object} message
+   *        The message itself
+   *        @param {EventTarget} sender
+   *        The source of the message
+   */
+  listen: function (callback) {
+    if (this._deliverCallback) {
+      throw new Error("Failed to listen. Listener already attached.");
+    } else if (!callback) {
+      throw new Error("Failed to listen. Callback argument missing.");
+    } else {
+      this._deliverCallback = callback;
+      this._broker.registerChannel(this);
+    }
+  },
+
+  /**
+   * Resets the callback for messages on this channel
+   * Removes the channel from the WebChannelBroker
+   */
+  stopListening: function () {
+    this._broker.unregisterChannel(this);
+    this._deliverCallback = null;
+  },
+
+  /**
+   * Sends messages over the WebChannel id using the "WebChannelMessageToContent" event
+   *
+   * @param message {Object}
+   *        The message object that will be sent
+   * @param target {browser}
+   *        The <browser> object that has a "messageManager" that sends messages
+   *
+   */
+  send: function (message, target) {
+    if (message && target && target.messageManager) {
+      target.messageManager.sendAsyncMessage("WebChannelMessageToContent", {
+        id: this.id,
+        message: message
+      });
+    } else if (!message) {
+      Cu.reportError("Failed to send a WebChannel message. Message not set.");
+    } else {
+      Cu.reportError("Failed to send a WebChannel message. Target invalid.");
+    }
+  },
+
+  /**
+   * Deliver WebChannel messages to the set "_channelCallback"
+   *
+   * @param data {Object}
+   *        Message data
+   * @param sender {browser}
+   *        Message sender
+   */
+  deliver: function(data, sender) {
+    if (this._deliverCallback) {
+      try {
+        this._deliverCallback(data.id, data.message, sender);
+      } catch (ex) {
+        this.send({
+          errno: ERRNO_UNKNOWN_ERROR,
+          error: ex.message ? ex.message : ERROR_UNKNOWN
+        }, sender);
+        Cu.reportError("Failed to execute callback:" + ex);
+      }
+    } else {
+      Cu.reportError("No callback set for this channel.");
+    }
+  }
+};
--- a/toolkit/modules/moz.build
+++ b/toolkit/modules/moz.build
@@ -50,16 +50,17 @@ EXTRA_JS_MODULES += [
     'sessionstore/XPathGenerator.jsm',
     'ShortcutUtils.jsm',
     'Sntp.jsm',
     'SpatialNavigation.jsm',
     'Sqlite.jsm',
     'Task.jsm',
     'TelemetryTimestamps.jsm',
     'Timer.jsm',
+    'WebChannel.jsm',
     'ZipUtils.jsm',
 ]
 
 EXTRA_PP_JS_MODULES += [
     'CertUtils.jsm',
     'GMPInstallManager.jsm',
     'ResetProfile.jsm',
     'Services.jsm',
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/xpcshell/test_web_channel.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const Cu = Components.utils;
+
+Cu.import("resource://services-common/async.js");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/WebChannel.jsm");
+
+const ERROR_ID_ORIGIN_REQUIRED = "WebChannel id and origin are required.";
+const VALID_WEB_CHANNEL_ID = "id";
+const URL_STRING = "http://example.com";
+const VALID_WEB_CHANNEL_ORIGIN = Services.io.newURI(URL_STRING, null, null);
+
+let MockWebChannelBroker = {
+  _channelMap: new Map(),
+  registerChannel: function(channel) {
+    if (!this._channelMap.has(channel)) {
+      this._channelMap.set(channel);
+    }
+  },
+  unregisterChannel: function (channelToRemove) {
+    this._channelMap.delete(channelToRemove)
+  }
+};
+
+function run_test() {
+  run_next_test();
+}
+
+/**
+ * Web channel tests
+ */
+
+/**
+ * Test channel listening
+ */
+add_test(function test_web_channel_listen() {
+  let channel = new WebChannel(VALID_WEB_CHANNEL_ID, VALID_WEB_CHANNEL_ORIGIN, {
+    broker: MockWebChannelBroker
+  });
+  let cb = Async.makeSpinningCallback();
+  let delivered = 0;
+  do_check_eq(channel.id, VALID_WEB_CHANNEL_ID);
+  do_check_eq(channel.origin.spec, VALID_WEB_CHANNEL_ORIGIN.spec);
+  do_check_eq(channel._deliverCallback, null);
+
+  channel.listen(function(id, message, target) {
+    do_check_eq(id, VALID_WEB_CHANNEL_ID);
+    do_check_true(message);
+    do_check_true(message.command);
+    do_check_true(target.sender);
+    delivered++;
+    // 2 messages should be delivered
+    if (delivered === 2) {
+      channel.stopListening();
+      do_check_eq(channel._deliverCallback, null);
+      cb();
+      run_next_test();
+    }
+  });
+
+  // send two messages
+  channel.deliver({
+    id: VALID_WEB_CHANNEL_ID,
+    message: {
+      command: "one"
+    }
+  }, { sender: true });
+
+  channel.deliver({
+    id: VALID_WEB_CHANNEL_ID,
+    message: {
+      command: "two"
+    }
+  }, { sender: true });
+
+  cb.wait();
+});
+
+
+/**
+ * Test constructor
+ */
+add_test(function test_web_channel_constructor() {
+  do_check_eq(constructorTester(), ERROR_ID_ORIGIN_REQUIRED);
+  do_check_eq(constructorTester(undefined), ERROR_ID_ORIGIN_REQUIRED);
+  do_check_eq(constructorTester(undefined, VALID_WEB_CHANNEL_ORIGIN), ERROR_ID_ORIGIN_REQUIRED);
+  do_check_eq(constructorTester(VALID_WEB_CHANNEL_ID, undefined), ERROR_ID_ORIGIN_REQUIRED);
+  do_check_false(constructorTester(VALID_WEB_CHANNEL_ID, VALID_WEB_CHANNEL_ORIGIN));
+
+  run_next_test();
+});
+
+function constructorTester(id, origin) {
+  try {
+    new WebChannel(id, origin);
+  } catch (e) {
+    return e.message;
+  }
+  return false;
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/xpcshell/test_web_channel_broker.js
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const Cu = Components.utils;
+
+Cu.import("resource://services-common/async.js");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/WebChannel.jsm");
+
+const VALID_WEB_CHANNEL_ID = "id";
+const URL_STRING = "http://example.com";
+const VALID_WEB_CHANNEL_ORIGIN = Services.io.newURI(URL_STRING, null, null);
+
+function run_test() {
+  run_next_test();
+}
+
+/**
+ * Test WebChannelBroker channel map
+ */
+add_test(function test_web_channel_broker_channel_map() {
+  let channel = new Object();
+  let channel2 = new Object();
+
+  do_check_eq(WebChannelBroker._channelMap.size, 0);
+  do_check_false(WebChannelBroker._messageListenerAttached);
+
+  // make sure _channelMap works correctly
+  WebChannelBroker.registerChannel(channel);
+  do_check_eq(WebChannelBroker._channelMap.size, 1);
+  do_check_true(WebChannelBroker._messageListenerAttached);
+
+  WebChannelBroker.registerChannel(channel2);
+  do_check_eq(WebChannelBroker._channelMap.size, 2);
+
+  WebChannelBroker.unregisterChannel(channel);
+  do_check_eq(WebChannelBroker._channelMap.size, 1);
+
+  // make sure the correct channel is unregistered
+  do_check_false(WebChannelBroker._channelMap.has(channel));
+  do_check_true(WebChannelBroker._channelMap.has(channel2));
+
+  WebChannelBroker.unregisterChannel(channel2);
+  do_check_eq(WebChannelBroker._channelMap.size, 0);
+
+  run_next_test();
+});
+
+
+/**
+ * Test WebChannelBroker _listener test
+ */
+add_test(function test_web_channel_broker_listener() {
+  let cb = Async.makeSpinningCallback();
+  var channel = new Object({
+    id: VALID_WEB_CHANNEL_ID,
+    origin: VALID_WEB_CHANNEL_ORIGIN,
+    deliver: function(data, sender) {
+      do_check_eq(data.id, VALID_WEB_CHANNEL_ID);
+      do_check_eq(data.message.command, "hello");
+      WebChannelBroker.unregisterChannel(channel);
+      cb();
+      run_next_test();
+    }
+  });
+
+  WebChannelBroker.registerChannel(channel);
+
+  var mockEvent = {
+    data: {
+      id: VALID_WEB_CHANNEL_ID,
+      message: {
+        command: "hello"
+      }
+    },
+    principal: {
+      origin: URL_STRING
+    }
+  };
+
+  WebChannelBroker._listener(mockEvent);
+  cb.wait();
+});
--- a/toolkit/modules/tests/xpcshell/xpcshell.ini
+++ b/toolkit/modules/tests/xpcshell/xpcshell.ini
@@ -23,9 +23,11 @@ support-files =
 [test_propertyListsUtils.js]
 [test_readCertPrefs.js]
 [test_Services.js]
 [test_sqlite.js]
 [test_sqlite_shutdown.js]
 [test_task.js]
 [test_TelemetryTimestamps.js]
 [test_timer.js]
+[test_web_channel.js]
+[test_web_channel_broker.js]
 [test_ZipUtils.js]