/* 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")); } let 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 = []; }, isSame: function(domain, protocol) { return this.domain === domain && ((protocol || "default") === this.protocol); }, /** * 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. * * This enqueues the message to send if the IdP hasn't signaled that it is * "READY", and sends the message when it is. * * 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 (!this.ready && 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; } // clear out before letting others know in case they do something bad let trackingCopy = this.tracking; let pendingCopy = this.pending; this.channel.close(); this._reset(); // dump a message of type "ERROR" in response to all outstanding // messages to the IdP let error = { type: "ERROR", message: "IdP closed" }; Object.keys(trackingCopy).forEach(function(k) { this.trackingCopy[k](error); }, this); pendingCopy.forEach(function(p) { p.callback(error); }, this); }, toString: function() { return this.domain + '/.../' + this.protocol; } }; this.IdpProxy = IdpProxy;