/* 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/. */

this.EXPORTED_SYMBOLS = ["PeerConnectionIdp"];

const {classes: Cc, interfaces: Ci, utils: Cu} = Components;

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "IdpProxy",
  "resource://gre/modules/media/IdpProxy.jsm");

/**
 * Creates an IdP helper.
 *
 * @param window (object) the window object to use for miscellaneous goodies
 * @param timeout (int) the timeout in milliseconds
 * @param warningFunc (function) somewhere to dump warning messages
 * @param dispatchEventFunc (function) somewhere to dump error events
 */
function PeerConnectionIdp(window, timeout, warningFunc, dispatchEventFunc) {
  this._win = window;
  this._timeout = timeout || 5000;
  this._warning = warningFunc;
  this._dispatchEvent = dispatchEventFunc;

  this.assertion = null;
  this.provider = null;
}

(function() {
  PeerConnectionIdp._mLinePattern = new RegExp("^m=", "m");
  // attributes are funny, the 'a' is case sensitive, the name isn't
  let pattern = "^a=[iI][dD][eE][nN][tT][iI][tT][yY]:(\\S+)";
  PeerConnectionIdp._identityPattern = new RegExp(pattern, "m");
  pattern = "^a=[fF][iI][nN][gG][eE][rR][pP][rR][iI][nN][tT]:(\\S+) (\\S+)";
  PeerConnectionIdp._fingerprintPattern = new RegExp(pattern, "m");
})();

PeerConnectionIdp.prototype = {
  setIdentityProvider: function(provider, protocol, username) {
    this.provider = provider;
    this.protocol = protocol;
    this.username = username;
    if (this._idpchannel) {
      if (this._idpchannel.isSame(provider, protocol)) {
        return;
      }
      this._idpchannel.close();
    }
    this._idpchannel = new IdpProxy(provider, protocol);
  },

  close: function() {
    this.assertion = null;
    this.provider = null;
    if (this._idpchannel) {
      this._idpchannel.close();
      this._idpchannel = null;
    }
  },

  /**
   * Generate an error event of the identified type;
   * and put a little more precise information in the console.
   */
  reportError: function(type, message, extra) {
    let args = {
      idp: this.provider,
      protocol: this.protocol
    };
    if (extra) {
      Object.keys(extra).forEach(function(k) {
        args[k] = extra[k];
      });
    }
    this._warning("RTC identity: " + message, null, 0);
    let ev = new this._win.RTCPeerConnectionIdentityErrorEvent('idp' + type + 'error', args);
    this._dispatchEvent(ev);
  },

  _getFingerprintFromSdp: function(sdp) {
    let sections = sdp.split(PeerConnectionIdp._mLinePattern);
    let attributes = sections.map(function(sect) {
      let m = sect.match(PeerConnectionIdp._fingerprintPattern);
      if (m) {
        let remainder = sect.substring(m.index + m[0].length);
        if (!remainder.match(PeerConnectionIdp._fingerprintPattern)) {
          return { algorithm: m[1], digest: m[2] };
        }
        this.reportError("validation", "two fingerprint values" +
                         " in same media section are not supported");
        // we have to return non-falsy here so that a media section doesn't
        // accidentally fall back to the session-level stuff (which is bad)
        return "error";
      }
      // return undefined unless there is exactly one match
    }, this);

    let sessionLevel = attributes.shift();
    attributes = attributes.map(function(sectionLevel) {
      return sectionLevel || sessionLevel;
    });

    let first = attributes.shift();
    function sameAsFirst(attr) {
      return typeof attr === "object" &&
      first.algorithm === attr.algorithm &&
      first.digest === attr.digest;
    }

    if (typeof first === "object" && attributes.every(sameAsFirst)) {
      return first;
    }
    // undefined!
  },

  _getIdentityFromSdp: function(sdp) {
    // a=identity is session level
    let mLineMatch = sdp.match(PeerConnectionIdp._mLinePattern);
    let sessionLevel = sdp.substring(0, mLineMatch.index);
    let idMatch = sessionLevel.match(PeerConnectionIdp._identityPattern);
    if (idMatch) {
      let assertion = {};
      try {
        assertion = JSON.parse(atob(idMatch[1]));
      } catch (e) {
        this.reportError("validation",
                         "invalid identity assertion: " + e);
      } // for JSON.parse
      if (typeof assertion.idp === "object" &&
          typeof assertion.idp.domain === "string" &&
          typeof assertion.assertion === "string") {
        return assertion;
      }

      this.reportError("validation", "assertion missing" +
                       " idp/idp.domain/assertion");
    }
    // undefined!
  },

  /**
   * Queues a task to verify the a=identity line the given SDP contains, if any.
   * If the verification succeeds callback is called with the message from the
   * IdP proxy as parameter, else (verification failed OR no a=identity line in
   * SDP at all) null is passed to callback.
   */
  verifyIdentityFromSDP: function(sdp, callback) {
    let identity = this._getIdentityFromSdp(sdp);
    let fingerprint = this._getFingerprintFromSdp(sdp);
    // it's safe to use the fingerprint we got from the SDP here,
    // only because we ensure that there is only one
    if (!fingerprint || !identity) {
      callback(null);
      return;
    }

    this.setIdentityProvider(identity.idp.domain, identity.idp.protocol);
    this._verifyIdentity(identity.assertion, fingerprint, callback);
  },

  /**
   * Checks that the name in the identity provided by the IdP is OK.
   *
   * @param name (string) the name to validate
   * @returns (string) an error message, iff the name isn't good
   */
  _validateName: function(name) {
    if (typeof name !== "string") {
      return "name not a string";
    }
    let atIdx = name.indexOf("@");
    if (atIdx > 0) {
      // no third party assertions... for now
      let tail = name.substring(atIdx + 1);

      // strip the port number, if present
      let provider = this.provider;
      let providerPortIdx = provider.indexOf(":");
      if (providerPortIdx > 0) {
        provider = provider.substring(0, providerPortIdx);
      }
      let idnService = Components.classes["@mozilla.org/network/idn-service;1"].
        getService(Components.interfaces.nsIIDNService);
      if (idnService.convertUTF8toACE(tail) !==
          idnService.convertUTF8toACE(provider)) {
        return "name '" + identity.name +
            "' doesn't match IdP: '" + this.provider + "'";
      }
      return null;
    }
    return "missing authority in name from IdP";
  },

  // we are very defensive here when handling the message from the IdP
  // proxy so that broken IdPs can only do as little harm as possible.
  _checkVerifyResponse: function(message, fingerprint) {
    let warn = function(msg) {
      this.reportError("validation",
                       "assertion validation failure: " + msg);
    }.bind(this);

    try {
      let contents = JSON.parse(message.contents);
      if (typeof contents.fingerprint !== "object") {
        warn("fingerprint is not an object");
      } else if (contents.fingerprint.digest !== fingerprint.digest ||
                 contents.fingerprint.algorithm !== fingerprint.algorithm) {
        warn("fingerprint does not match");
      } else {
        let error = this._validateName(message.identity);
        if (error) {
          warn(error);
        } else {
          return true;
        }
      }
    } catch(e) {
      warn("invalid JSON in content");
    }
    return false;
  },

  /**
   * Asks the IdP proxy to verify an identity.
   */
  _verifyIdentity: function(
      assertion, fingerprint, callback) {
    function onVerification(message) {
      if (message && this._checkVerifyResponse(message, fingerprint)) {
        callback(message);
      } else {
        this._warning("RTC identity: assertion validation failure", null, 0);
        callback(null);
      }
    }

    let request = {
      type: "VERIFY",
      message: assertion
    };
    this._sendToIdp(request, "validation", onVerification.bind(this));
  },

  /**
   * Asks the IdP proxy for an identity assertion and, on success, enriches the
   * given SDP with an a=identity line and calls callback with the new SDP as
   * parameter. If no IdP is configured the original SDP (without a=identity
   * line) is passed to the callback.
   */
  appendIdentityToSDP: function(sdp, fingerprint, callback) {
    let onAssertion = function() {
      callback(this.wrapSdp(sdp), this.assertion);
    }.bind(this);

    if (!this._idpchannel || this.assertion) {
      onAssertion();
      return;
    }

    this._getIdentityAssertion(fingerprint, onAssertion);
  },

  /**
   * Inserts an identity assertion into the given SDP.
   */
  wrapSdp: function(sdp) {
    if (!this.assertion) {
      return sdp;
    }

    // yes, we assume that this matches; if it doesn't something is *wrong*
    let match = sdp.match(PeerConnectionIdp._mLinePattern);
    return sdp.substring(0, match.index) +
      "a=identity:" + this.assertion + "\r\n" +
      sdp.substring(match.index);
  },

  getIdentityAssertion: function(fingerprint, callback) {
    if (!this._idpchannel) {
      this.reportError("assertion", "IdP not set");
      callback(null);
      return;
    }

    this._getIdentityAssertion(fingerprint, callback);
  },

  _getIdentityAssertion: function(fingerprint, callback) {
    let [algorithm, digest] = fingerprint.split(" ");
    let message = {
      fingerprint: {
        algorithm: algorithm,
        digest: digest
      }
    };
    let request = {
      type: "SIGN",
      message: JSON.stringify(message),
      username: this.username
    };

    // catch the assertion, clean it up, warn if absent
    function trapAssertion(assertion) {
      if (!assertion) {
        this._warning("RTC identity: assertion generation failure", null, 0);
        this.assertion = null;
      } else {
        this.assertion = btoa(JSON.stringify(assertion));
      }
      callback(this.assertion);
    }

    this._sendToIdp(request, "assertion", trapAssertion.bind(this));
  },

  /**
   * Packages a message and sends it to the IdP.
   * @param request (dictionary) the message to send
   * @param type (DOMString) the type of message (assertion/validation)
   * @param callback (function) the function to call with the results
   */
  _sendToIdp: function(request, type, callback) {
    request.origin = Cu.getWebIDLCallerPrincipal().origin;
    this._idpchannel.send(request, this._wrapCallback(type, callback));
  },

  _reportIdpError: function(type, message) {
    let args = {};
    let msg = "";
    if (message.type === "ERROR") {
      msg = message.error;
    } else {
      msg = JSON.stringify(message.message);
      if (message.type === "LOGINNEEDED") {
        args.loginUrl = message.loginUrl;
      }
    }
    this.reportError(type, "received response of type '" +
                     message.type + "' from IdP: " + msg, args);
  },

  /**
   * Wraps a callback, adding a timeout and ensuring that the callback doesn't
   * receive any message other than one where the IdP generated a "SUCCESS"
   * response.
   */
  _wrapCallback: function(type, callback) {
    let timeout = this._win.setTimeout(function() {
      this.reportError(type, "IdP timeout for " + this._idpchannel + " " +
                       (this._idpchannel.ready ? "[ready]" : "[not ready]"));
      timeout = null;
      callback(null);
    }.bind(this), this._timeout);

    return function(message) {
      if (!timeout) {
        return;
      }
      this._win.clearTimeout(timeout);
      timeout = null;

      let content = null;
      if (message.type === "SUCCESS") {
        content = message.message;
      } else {
        this._reportIdpError(type, message);
      }
      callback(content);
    }.bind(this);
  }
};

this.PeerConnectionIdp = PeerConnectionIdp;
