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 170788 c3dc9884b98894d930d1b79df0d9dddb32e2c584
parent 170787 5e4b40a4da7c1a7c177cc54d90765635b8adc0ef
child 170789 3c2965b5214b3ecae193fe1fc6d54eb6ef76001f
push id270
push userpvanderbeken@mozilla.com
push dateThu, 06 Mar 2014 09:24:21 +0000
reviewersabr
bugs878941
milestone30.0a1
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);
       }