Bug 878941 - Add IdP proxy for WebRTC. r=abr
author"Martin Thomson [:mt]" <martin.thomson@gmail.com>
Thu, 20 Feb 2014 16:26:16 -0800
changeset 170540 c3dc9884b98894d930d1b79df0d9dddb32e2c584
parent 170539 5e4b40a4da7c1a7c177cc54d90765635b8adc0ef
child 170541 3c2965b5214b3ecae193fe1fc6d54eb6ef76001f
push id26291
push userkwierso@gmail.com
push dateWed, 26 Feb 2014 04:10:11 +0000
treeherdermozilla-central@626d99c084cb [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersabr
bugs878941
milestone30.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 878941 - Add IdP proxy for WebRTC. r=abr
dom/media/IdpProxy.jsm
dom/media/PeerConnection.js
dom/media/moz.build
dom/media/tests/identity/idp-proxy.js
dom/media/tests/identity/idp.html
dom/media/tests/identity/mochitest.ini
dom/media/tests/identity/moz.build
dom/media/tests/identity/test_idpproxy.html
dom/media/tests/mochitest/head.js
new file mode 100644
--- /dev/null
+++ b/dom/media/IdpProxy.jsm
@@ -0,0 +1,260 @@
+/* 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/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["IdpProxy"];
+
+const {
+  classes: Cc,
+  interfaces: Ci,
+  utils: Cu,
+  results: Cr
+} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Sandbox",
+                                  "resource://gre/modules/identity/Sandbox.jsm");
+
+/**
+ * An invisible iframe for hosting the idp shim.
+ *
+ * There is no visible UX here, as we assume the user has already
+ * logged in elsewhere (on a different screen in the web site hosting
+ * the RTC functions).
+ */
+function IdpChannel(uri, messageCallback) {
+  this.sandbox = null;
+  this.messagechannel = null;
+  this.source = uri;
+  this.messageCallback = messageCallback;
+}
+
+IdpChannel.prototype = {
+  /**
+   * Create a hidden, sandboxed iframe for hosting the IdP's js shim.
+   *
+   * @param callback
+   *                (function) invoked when this completes, with an error
+   *                argument if there is a problem, no argument if everything is
+   *                ok
+   */
+  open: function(callback) {
+    if (this.sandbox) {
+      return callback(new Error("IdP channel already open"));
+    }
+
+    var ready = this._sandboxReady.bind(this, callback);
+    this.sandbox = new Sandbox(this.source, ready);
+  },
+
+  _sandboxReady: function(aCallback, aSandbox) {
+    // Inject a message channel into the subframe.
+    this.messagechannel = new aSandbox._frame.contentWindow.MessageChannel();
+    try {
+      Object.defineProperty(
+        aSandbox._frame.contentWindow.wrappedJSObject,
+        "rtcwebIdentityPort",
+        {
+          value: this.messagechannel.port2
+        }
+      );
+    } catch (e) {
+      this.close();
+      aCallback(e); // oops, the IdP proxy overwrote this.. bad
+      return;
+    }
+    this.messagechannel.port1.onmessage = function(msg) {
+      this.messageCallback(msg.data);
+    }.bind(this);
+    this.messagechannel.port1.start();
+    aCallback();
+  },
+
+  send: function(msg) {
+    this.messagechannel.port1.postMessage(msg);
+  },
+
+  close: function IdpChannel_close() {
+    if (this.sandbox) {
+      if (this.messagechannel) {
+        this.messagechannel.port1.close();
+      }
+      this.sandbox.free();
+    }
+    this.messagechannel = null;
+    this.sandbox = null;
+  }
+};
+
+/**
+ * A message channel between the RTC PeerConnection and a designated IdP Proxy.
+ *
+ * @param domain (string) the domain to load up
+ * @param protocol (string) Optional string for the IdP protocol
+ */
+function IdpProxy(domain, protocol) {
+  IdpProxy.validateDomain(domain);
+  IdpProxy.validateProtocol(protocol);
+
+  this.domain = domain;
+  this.protocol = protocol || "default";
+
+  this._reset();
+}
+
+/**
+ * Checks that the domain is only a domain, and doesn't contain anything else.
+ * Adds it to a URI, then checks that it matches perfectly.
+ */
+IdpProxy.validateDomain = function(domain) {
+  let message = "Invalid domain for identity provider; ";
+  if (!domain || typeof domain !== "string") {
+    throw new Error(message + "must be a non-zero length string");
+  }
+
+  message += "must only have a domain name and optionally a port";
+  try {
+    let ioService = Components.classes["@mozilla.org/network/io-service;1"]
+                    .getService(Components.interfaces.nsIIOService);
+    let uri = ioService.newURI('https://' + domain + '/', null, null);
+
+    // this should trap errors
+    // we could check uri.userPass, uri.path and uri.ref, but there is no need
+    if (uri.hostPort !== domain) {
+      throw new Error(message);
+    }
+  } catch (e if (e.result === Cr.NS_ERROR_MALFORMED_URI)) {
+    throw new Error(message);
+  }
+};
+
+/**
+ * Checks that the IdP protocol is sane.  In particular, we don't want someone
+ * adding relative paths (e.g., "../../myuri"), which could be used to move
+ * outside of /.well-known/ and into space that they control.
+ */
+IdpProxy.validateProtocol = function(protocol) {
+  if (!protocol) {
+    return;  // falsy values turn into "default", so they are OK
+  }
+  let message = "Invalid protocol for identity provider; ";
+  if (typeof protocol !== "string") {
+    throw new Error(message + "must be a string");
+  }
+  if (decodeURIComponent(protocol).match(/[\/\\]/)) {
+    throw new Error(message + "must not include '/' or '\\'");
+  }
+};
+
+IdpProxy.prototype = {
+  _reset: function() {
+    this.channel = null;
+    this.ready = false;
+
+    this.counter = 0;
+    this.tracking = {};
+    this.pending = [];
+  },
+
+  /**
+   * Get a sandboxed iframe for hosting the idp-proxy's js. Create a message
+   * channel down to the frame.
+   *
+   * @param errorCallback (function) a callback that will be invoked if there
+   *                is a fatal error starting the proxy
+   */
+  start: function(errorCallback) {
+    if (this.channel) {
+      return;
+    }
+    let well_known = "https://" + this.domain;
+    well_known += "/.well-known/idp-proxy/" + this.protocol;
+    this.channel = new IdpChannel(well_known, this._messageReceived.bind(this));
+    this.channel.open(function(error) {
+      if (error) {
+        this.close();
+        if (typeof errorCallback === "function") {
+          errorCallback(error);
+        }
+      }
+    }.bind(this));
+  },
+
+  /**
+   * Send a message up to the idp proxy. This should be an RTC "SIGN" or
+   * "VERIFY" message. This method adds the tracking 'id' parameter
+   * automatically to the message so that the callback is only invoked for the
+   * response to the message.
+   *
+   * The caller is responsible for ensuring that a response is received. If the
+   * IdP doesn't respond, the callback simply isn't invoked.
+   */
+  send: function(message, callback) {
+    this.start();
+    if (this.ready) {
+      message.id = "" + (++this.counter);
+      this.tracking[message.id] = callback;
+      this.channel.send(message);
+    } else {
+      this.pending.push({ message: message, callback: callback });
+    }
+  },
+
+  /**
+   * Handle a message from the IdP. This automatically sends if the message is
+   * 'READY' so there is no need to track readiness state outside of this obj.
+   */
+  _messageReceived: function(message) {
+    if (!message) {
+      return;
+    }
+    if (message.type === "READY") {
+      this.ready = true;
+      this.pending.forEach(function(p) {
+        this.send(p.message, p.callback);
+      }, this);
+      this.pending = [];
+    } else if (this.tracking[message.id]) {
+      var callback = this.tracking[message.id];
+      delete this.tracking[message.id];
+      callback(message);
+    } else {
+      let console = Cc["@mozilla.org/consoleservice;1"].
+        getService(Ci.nsIConsoleService);
+      console.logStringMessage("Received bad message from IdP: " +
+                               message.id + ":" + message.type);
+    }
+  },
+
+  /**
+   * Performs cleanup.  The object should be OK to use again.
+   */
+  close: function() {
+    if (!this.channel) {
+      return;
+    }
+
+    // dump a message of type "ERROR" in response to all outstanding
+    // messages to the IdP
+    let error = { type: "ERROR" };
+    Object.keys(this.tracking).forEach(function(k) {
+      this.tracking[k](error);
+    }, this);
+    this.pending.forEach(function(p) {
+      p.callback(error);
+    }, this);
+
+    this.channel.close();
+    this._reset();
+  },
+
+  toString: function() {
+    return this.domain + '/' + this.protocol;
+  }
+};
+
+this.IdpProxy = IdpProxy;
--- a/dom/media/PeerConnection.js
+++ b/dom/media/PeerConnection.js
@@ -1,8 +1,9 @@
+/* jshint moz:true, browser:true */
 /* 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/. */
 
 "use strict";
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
--- a/dom/media/moz.build
+++ b/dom/media/moz.build
@@ -7,17 +7,17 @@
 if CONFIG['MOZ_WEBRTC']:
     DIRS += ['bridge']
 
     LOCAL_INCLUDES += [
         '/media/webrtc/signaling/src/common',
         '/media/webrtc/trunk',
     ]
 
-TEST_DIRS += ['tests/mochitest', 'tests/ipc']
+TEST_DIRS += ['tests/mochitest', 'tests/ipc', 'tests/identity']
 
 XPIDL_SOURCES += [
     'nsIDOMMediaStream.idl',
     'nsIDOMNavigatorUserMedia.idl',
     'nsIMediaManager.idl',
 ]
 
 XPIDL_MODULE = 'dom_media'
@@ -35,16 +35,22 @@ UNIFIED_SOURCES += [
     'MediaManager.cpp',
 ]
 
 EXTRA_COMPONENTS += [
     'PeerConnection.js',
     'PeerConnection.manifest',
 ]
 
+JS_MODULES_PATH = 'modules/media'
+
+EXTRA_JS_MODULES += [
+    'IdpProxy.jsm',
+]
+
 if CONFIG['MOZ_B2G']:
     EXPORTS.mozilla += [
         'MediaPermissionGonk.h',
     ]
     SOURCES += [
         'MediaPermissionGonk.cpp',
     ]
 
new file mode 100644
--- /dev/null
+++ b/dom/media/tests/identity/idp-proxy.js
@@ -0,0 +1,96 @@
+(function(global) {
+  "use strict";
+
+  function IDPJS() {
+    this.domain = window.location.host;
+    // so rather than create a million different IdP configurations and litter
+    // the world with files all containing near-identical code, let's use the
+    // hash/URL fragment as a way of generating instructions for the IdP
+    this.instructions = window.location.hash.replace("#", "").split(":");
+    this.port = window.rtcwebIdentityPort;
+    this.port.onmessage = this.receiveMessage.bind(this);
+    this.sendResponse({
+      type : "READY"
+    });
+  }
+
+  IDPJS.prototype.getDelay = function() {
+    // instructions in the form "delay123" have that many milliseconds
+    // added before sending the response
+    var delay = 0;
+    function addDelay(instruction) {
+      var m = instruction.match(/^delay(\d+)$/);
+      if (m) {
+        delay += parseInt(m[1], 10);
+      }
+    }
+    this.instructions.forEach(addDelay);
+    return delay;
+  };
+
+  function is(target) {
+    return function(instruction) {
+      return instruction === target;
+    };
+  }
+
+  IDPJS.prototype.sendResponse = function(response) {
+    // we don't touch the READY message unless told to
+    if (response.type === "READY" && !this.instructions.some(is("ready"))) {
+      this.port.postMessage(response);
+      return;
+    }
+
+    // if any instruction is "error", return an error.
+    if (this.instructions.some(is("error"))) {
+      response.type = "ERROR";
+    }
+
+    window.setTimeout(function() {
+      this.port.postMessage(response);
+    }.bind(this), this.getDelay());
+  };
+
+  IDPJS.prototype.receiveMessage = function(ev) {
+    var message = ev.data;
+    switch (message.type) {
+    case "SIGN":
+      this.sendResponse({
+        type : "SUCCESS",
+        id : message.id,
+        message : {
+          idp : {
+            domain : this.domain,
+            protocol : "idp.html"
+          },
+          assertion : JSON.stringify({
+            identity : "someone@" + this.domain,
+            contents : message.message
+          })
+        }
+      });
+      break;
+    case "VERIFY":
+      this.sendResponse({
+        type : "SUCCESS",
+        id : message.id,
+        message : {
+          identity : {
+            name : "someone@" + this.domain,
+            displayname : "Someone"
+          },
+          contents : JSON.parse(message.message).contents
+        }
+      });
+      break;
+    default:
+      this.sendResponse({
+        type : "ERROR",
+        error : JSON.stringify(message)
+      });
+      break;
+    }
+  };
+
+  global.idp = new IDPJS();
+}(this));
new file mode 100644
--- /dev/null
+++ b/dom/media/tests/identity/idp.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <title>IDP Proxy</title>
+    <script src="idp-proxy.js"></script>
+  </head>
+  <body>
+    Test IDP Proxy
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/dom/media/tests/identity/mochitest.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+support-files =
+  /.well-known/idp-proxy/idp.html
+  /.well-known/idp-proxy/idp-proxy.js
+
+# All tests are disabled on android due to lack of https support in mochitest
+# (Bug 975149)
+# All tests are disabled on b2g due to lack of e10s support in WebRTC identity
+# (Bug 975144)
+[test_idpproxy.html]
+skip-if = os == "android" || appname == "b2g"
+[../mochitest/test_zmedia_cleanup.html]
+skip-if = os == "android" || appname == "b2g"
new file mode 100644
--- /dev/null
+++ b/dom/media/tests/identity/moz.build
@@ -0,0 +1,8 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+MOCHITEST_MANIFESTS += ['mochitest.ini']
+
new file mode 100644
--- /dev/null
+++ b/dom/media/tests/identity/test_idpproxy.html
@@ -0,0 +1,131 @@
+<html>
+<head>
+<meta charset="utf-8" />
+<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+<script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+</head>
+<body>
+  <script class="testbody" type="application/javascript">
+"use strict";
+var Cu = SpecialPowers.Cu;
+var rtcid = Cu.import("resource://gre/modules/media/IdpProxy.jsm");
+var IdpProxy = rtcid.IdpProxy;
+var request = {
+  type: "SIGN",
+  message: "foo"
+};
+
+function test_domain_sandbox(done) {
+  var diabolical = {
+    toString : function() {
+      return "example.com/path";
+    }
+  };
+  var domains = [ "ex/foo", "user@ex", "user:pass@ex", "ex#foo", "ex?foo",
+                  "", 12, null, diabolical, true ];
+  domains.forEach(function(domain) {
+    try {
+      var idp = new IdpProxy(domain);
+      ok(false, "IdpProxy didn't catch bad domain: " + domain);
+    } catch (e) {
+      var str = (typeof domain === "string") ? domain : typeof domain;
+      ok(true, "Evil domain '" + str + "' raises exception");
+    }
+  });
+  done();
+}
+
+function test_protocol_sandbox(done) {
+  var protos = [ "../evil/proto", "..%2Fevil%2Fproto",
+                 "\\evil", "%5cevil", 12, true, {} ];
+  protos.forEach(function(proto) {
+    try {
+      var idp = new IdpProxy("example.com", proto);
+      ok(false, "IdpProxy didn't catch bad protocol: " + proto);
+    } catch (e) {
+      var str = (typeof proto === "string") ? proto : typeof proto;
+      ok(true, "Evil protocol '" + proto + "' raises exception");
+    }
+  });
+  done();
+}
+
+function handleFailure(done) {
+  return function failure(error) {
+    ok(false, "IdP error" + error);
+    done();
+  };
+}
+
+function test_success_response(done) {
+  var idp;
+  var failure = handleFailure(done);
+  var timeout = setTimeout(failure, 5000);;
+
+  function handleResponse(response) {
+    is(SpecialPowers.wrap(response).type, "SUCCESS", "IdP responds with SUCCESS");
+    idp.close();
+    clearTimeout(timeout);
+    done();
+  }
+
+  idp = new IdpProxy("example.com", "idp.html");
+  idp.start(failure);
+  idp.send(request, handleResponse);
+}
+
+function test_error_response(done) {
+  var idp;
+  var failure = handleFailure(done);
+  var timeout = setTimeout(failure, 5000);;
+
+  function handleResponse(response) {
+    is(SpecialPowers.wrap(response).type, "ERROR", "IdP should produce ERROR");
+    idp.close();
+    clearTimeout(timeout);
+    done();
+  }
+
+  idp = new IdpProxy("example.com", "idp.html#error");
+  idp.start(failure);
+  idp.send(request, handleResponse);
+}
+
+function test_delayed_response(done) {
+  var idp;
+  var failure = handleFailure(done);
+  var timeout = setTimeout(failure, 5000);;
+
+  function handleResponse(response) {
+    is(SpecialPowers.wrap(response).type, "SUCCESS",
+       "IdP should handle delayed response");
+    idp.close();
+    clearTimeout(timeout);
+    done();
+  }
+
+  idp = new IdpProxy("example.com", "idp.html#delay100");
+  idp.start(failure);
+  idp.send(request, handleResponse);
+}
+
+var TESTS = [ test_domain_sandbox, test_protocol_sandbox,
+              test_success_response, test_error_response,
+              test_delayed_response ];
+
+function run_next_test() {
+  if (TESTS.length) {
+    var test = TESTS.shift();
+    test(run_next_test);
+  } else {
+    SimpleTest.finish();
+  }
+}
+
+SimpleTest.waitForExplicitFinish();
+SpecialPowers.pushPrefEnv({
+  "set" : [ [ "dom.messageChannel.enabled", true ] ]
+}, run_next_test);
+</script>
+  </body>
+</html>
--- a/dom/media/tests/mochitest/head.js
+++ b/dom/media/tests/mochitest/head.js
@@ -112,17 +112,20 @@ function getUserMedia(constraints, onSuc
  * @param {Function} aCallback
  *        Test method to execute after initialization
  */
 function runTest(aCallback) {
   if (window.SimpleTest) {
     // Running as a Mochitest.
     SimpleTest.waitForExplicitFinish();
     SpecialPowers.pushPrefEnv({'set': [
+      ['dom.messageChannel.enabled', true],
       ['media.peerconnection.enabled', true],
+      ['media.peerconnection.identity.enabled', true],
+      ['media.peerconnection.identity.timeout', 3000],
       ['media.navigator.permission.disabled', true]]
     }, function () {
       try {
         aCallback();
       }
       catch (err) {
         unexpectedCallbackAndFinish()(err);
       }