/* -*- Mode: js2; js2-basic-offset: 2; indent-tabs-mode: nil; -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* 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 Cu = Components.utils;
const Ci = Components.interfaces;
const Cc = Components.classes;
const Cr = Components.results;

Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/identity/LogUtils.jsm");
Cu.import("resource://gre/modules/identity/Sandbox.jsm");

this.EXPORTED_SYMBOLS = ["IdentityProvider"];
const FALLBACK_PROVIDER = "browserid.org";

XPCOMUtils.defineLazyModuleGetter(this,
                                  "jwcrypto",
                                  "resource://gre/modules/identity/jwcrypto.jsm");

function log(...aMessageArgs) {
  Logger.log.apply(Logger, ["IDP"].concat(aMessageArgs));
}
function reportError(...aMessageArgs) {
  Logger.reportError.apply(Logger, ["IDP"].concat(aMessageArgs));
}


function IdentityProviderService() {
  XPCOMUtils.defineLazyModuleGetter(this,
                                    "_store",
                                    "resource://gre/modules/identity/IdentityStore.jsm",
                                    "IdentityStore");

  this.reset();
}

IdentityProviderService.prototype = {
  QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]),
  _sandboxConfigured: false,

  observe: function observe(aSubject, aTopic, aData) {
    switch (aTopic) {
      case "quit-application-granted":
        Services.obs.removeObserver(this, "quit-application-granted");
        this.shutdown();
        break;
    }
  },

  reset: function IDP_reset() {
    // Clear the provisioning flows.  Provision flows contain an
    // identity, idpParams (how to reach the IdP to provision and
    // authenticate), a callback (a completion callback for when things
    // are done), and a provisioningFrame (which is the provisioning
    // sandbox).  Additionally, two callbacks will be attached:
    // beginProvisioningCallback and genKeyPairCallback.
    this._provisionFlows = {};

    // Clear the authentication flows.  Authentication flows attach
    // to provision flows.  In the process of provisioning an id, it
    // may be necessary to authenticate with an IdP.  The authentication
    // flow maintains the state of that authentication process.
    this._authenticationFlows = {};
  },

  getProvisionFlow: function getProvisionFlow(aProvId, aErrBack) {
    let provFlow = this._provisionFlows[aProvId];
    if (provFlow) {
      return provFlow;
    }

    let err = "No provisioning flow found with id " + aProvId;
    log("ERROR:", err);
    if (typeof aErrBack === 'function') {
      aErrBack(err);
    }
  },

  shutdown: function RP_shutdown() {
    this.reset();

    if (this._sandboxConfigured) {
      // Tear down message manager listening on the hidden window
      Cu.import("resource://gre/modules/DOMIdentity.jsm");
      DOMIdentity._configureMessages(Services.appShell.hiddenDOMWindow, false);
      this._sandboxConfigured = false;
    }

    Services.obs.removeObserver(this, "quit-application-granted");
  },

  get securityLevel() {
    return 1;
  },

  get certDuration() {
    switch(this.securityLevel) {
      default:
        return 3600;
    }
  },

  /**
   * Provision an Identity
   *
   * @param aIdentity
   *        (string) the email we're logging in with
   *
   * @param aIDPParams
   *        (object) parameters of the IdP
   *
   * @param aCallback
   *        (function) callback to invoke on completion
   *                   with first-positional parameter the error.
   */
  _provisionIdentity: function _provisionIdentity(aIdentity, aIDPParams, aProvId, aCallback) {
    let provPath = aIDPParams.idpParams.provisioning;
    let url = Services.io.newURI("https://" + aIDPParams.domain, null, null).resolve(provPath);
    log("_provisionIdentity: identity:", aIdentity, "url:", url);

    // If aProvId is not null, then we already have a flow
    // with a sandbox.  Otherwise, get a sandbox and create a
    // new provision flow.

    if (aProvId) {
      // Re-use an existing sandbox
      log("_provisionIdentity: re-using sandbox in provisioning flow with id:", aProvId);
      this._provisionFlows[aProvId].provisioningSandbox.reload();

    } else {
      this._createProvisioningSandbox(url, function createdSandbox(aSandbox) {
        // create a provisioning flow, using the sandbox id, and
        // stash callback associated with this provisioning workflow.

        let provId = aSandbox.id;
        this._provisionFlows[provId] = {
          identity: aIdentity,
          idpParams: aIDPParams,
          securityLevel: this.securityLevel,
          provisioningSandbox: aSandbox,
          callback: function doCallback(aErr) {
            aCallback(aErr, provId);
          },
        };

        log("_provisionIdentity: Created sandbox and provisioning flow with id:", provId);
        // XXX bug 769862 - provisioning flow should timeout after N seconds

      }.bind(this));
    }
  },

  // DOM Methods
  /**
   * the provisioning iframe sandbox has called navigator.id.beginProvisioning()
   *
   * @param aCaller
   *        (object)  the iframe sandbox caller with all callbacks and
   *                  other information.  Callbacks include:
   *                  - doBeginProvisioningCallback(id, duration_s)
   *                  - doGenKeyPairCallback(pk)
   */
  beginProvisioning: function beginProvisioning(aCaller) {
    log("beginProvisioning:", aCaller.id);

    // Expect a flow for this caller already to be underway.
    let provFlow = this.getProvisionFlow(aCaller.id, aCaller.doError);

    // keep the caller object around
    provFlow.caller = aCaller;

    let identity = provFlow.identity;
    let frame = provFlow.provisioningFrame;

    // Determine recommended length of cert.
    let duration = this.certDuration;

    // Make a record that we have begun provisioning.  This is required
    // for genKeyPair.
    provFlow.didBeginProvisioning = true;

    // Let the sandbox know to invoke the callback to beginProvisioning with
    // the identity and cert length.
    return aCaller.doBeginProvisioningCallback(identity, duration);
  },

  /**
   * the provisioning iframe sandbox has called
   * navigator.id.raiseProvisioningFailure()
   *
   * @param aProvId
   *        (int)  the identifier of the provisioning flow tied to that sandbox
   * @param aReason
   */
  raiseProvisioningFailure: function raiseProvisioningFailure(aProvId, aReason) {
    reportError("Provisioning failure", aReason);

    // look up the provisioning caller and its callback
    let provFlow = this.getProvisionFlow(aProvId);

    // Sandbox is deleted in _cleanUpProvisionFlow in case we re-use it.

    // This may be either a "soft" or "hard" fail.  If it's a
    // soft fail, we'll flow through setAuthenticationFlow, where
    // the provision flow data will be copied into a new auth
    // flow.  If it's a hard fail, then the callback will be
    // responsible for cleaning up the now defunct provision flow.

    // invoke the callback with an error.
    provFlow.callback(aReason);
  },

  /**
   * When navigator.id.genKeyPair is called from provisioning iframe sandbox.
   * Generates a keypair for the current user being provisioned.
   *
   * @param aProvId
   *        (int)  the identifier of the provisioning caller tied to that sandbox
   *
   * It is an error to call genKeypair without receiving the callback for
   * the beginProvisioning() call first.
   */
  genKeyPair: function genKeyPair(aProvId) {
    // Look up the provisioning caller and make sure it's valid.
    let provFlow = this.getProvisionFlow(aProvId);

    if (!provFlow.didBeginProvisioning) {
      let errStr = "ERROR: genKeyPair called before beginProvisioning";
      log(errStr);
      provFlow.callback(errStr);
      return;
    }

    // Ok generate a keypair
    jwcrypto.generateKeyPair(jwcrypto.ALGORITHMS.DS160, function gkpCb(err, kp) {
      log("in gkp callback");
      if (err) {
        log("ERROR: genKeyPair:", err);
        provFlow.callback(err);
        return;
      }

      provFlow.kp = kp;

      // Serialize the publicKey of the keypair and send it back to the
      // sandbox.
      log("genKeyPair: generated keypair for provisioning flow with id:", aProvId);
      provFlow.caller.doGenKeyPairCallback(provFlow.kp.serializedPublicKey);
    }.bind(this));
  },

  /**
   * When navigator.id.registerCertificate is called from provisioning iframe
   * sandbox.
   *
   * Sets the certificate for the user for which a certificate was requested
   * via a preceding call to beginProvisioning (and genKeypair).
   *
   * @param aProvId
   *        (integer) the identifier of the provisioning caller tied to that
   *                  sandbox
   *
   * @param aCert
   *        (String)  A JWT representing the signed certificate for the user
   *                  being provisioned, provided by the IdP.
   */
  registerCertificate: function registerCertificate(aProvId, aCert) {
    log("registerCertificate:", aProvId, aCert);

    // look up provisioning caller, make sure it's valid.
    let provFlow = this.getProvisionFlow(aProvId);

    if (!provFlow.caller) {
      reportError("registerCertificate", "No provision flow or caller");
      return;
    }
    if (!provFlow.kp)  {
      let errStr = "Cannot register a certificate without a keypair";
      reportError("registerCertificate", errStr);
      provFlow.callback(errStr);
      return;
    }

    // store the keypair and certificate just provided in IDStore.
    this._store.addIdentity(provFlow.identity, provFlow.kp, aCert);

    // Great success!
    provFlow.callback(null);

    // Clean up the flow.
    this._cleanUpProvisionFlow(aProvId);
  },

  /**
   * Begin the authentication process with an IdP
   *
   * @param aProvId
   *        (int) the identifier of the provisioning flow which failed
   *
   * @param aCallback
   *        (function) to invoke upon completion, with
   *                   first-positional-param error.
   */
  _doAuthentication: function _doAuthentication(aProvId, aIDPParams) {
    log("_doAuthentication: provId:", aProvId, "idpParams:", aIDPParams);
    // create an authentication caller and its identifier AuthId
    // stash aIdentity, idpparams, and callback in it.

    // extract authentication URL from idpParams
    let authPath = aIDPParams.idpParams.authentication;
    let authURI = Services.io.newURI("https://" + aIDPParams.domain, null, null).resolve(authPath);

    // beginAuthenticationFlow causes the "identity-auth" topic to be
    // observed.  Since it's sending a notification to the DOM, there's
    // no callback.  We wait for the DOM to trigger the next phase of
    // provisioning.
    this._beginAuthenticationFlow(aProvId, authURI);

    // either we bind the AuthID to the sandbox ourselves, or UX does that,
    // in which case we need to tell UX the AuthId.
    // Currently, the UX creates the UI and gets the AuthId from the window
    // and sets is with setAuthenticationFlow
  },

  /**
   * The authentication frame has called navigator.id.beginAuthentication
   *
   * IMPORTANT: the aCaller is *always* non-null, even if this is called from
   * a regular content page. We have to make sure, on every DOM call, that
   * aCaller is an expected authentication-flow identifier. If not, we throw
   * an error or something.
   *
   * @param aCaller
   *        (object)  the authentication caller
   *
   */
  beginAuthentication: function beginAuthentication(aCaller) {
    log("beginAuthentication: caller id:", aCaller.id);

    // Begin the authentication flow after having concluded a provisioning
    // flow.  The aCaller that the DOM gives us will have the same ID as
    // the provisioning flow we just concluded.  (see setAuthenticationFlow)
    let authFlow = this._authenticationFlows[aCaller.id];
    if (!authFlow) {
      return aCaller.doError("beginAuthentication: no flow for caller id", aCaller.id);
    }

    authFlow.caller = aCaller;

    let identity = this._provisionFlows[authFlow.provId].identity;

    // tell the UI to start the authentication process
    log("beginAuthentication: authFlow:", aCaller.id, "identity:", identity);
    return authFlow.caller.doBeginAuthenticationCallback(identity);
  },

  /**
   * The auth frame has called navigator.id.completeAuthentication
   *
   * @param aAuthId
   *        (int)  the identifier of the authentication caller tied to that sandbox
   *
   */
  completeAuthentication: function completeAuthentication(aAuthId) {
    log("completeAuthentication:", aAuthId);

    // look up the AuthId caller, and get its callback.
    let authFlow = this._authenticationFlows[aAuthId];
    if (!authFlow) {
      reportError("completeAuthentication", "No auth flow with id", aAuthId);
      return;
    }
    let provId = authFlow.provId;

    // delete caller
    delete authFlow['caller'];
    delete this._authenticationFlows[aAuthId];

    let provFlow = this.getProvisionFlow(provId);
    provFlow.didAuthentication = true;
    let subject = {
      rpId: provFlow.rpId,
      identity: provFlow.identity,
    };
    Services.obs.notifyObservers({ wrappedJSObject: subject }, "identity-auth-complete", aAuthId);
  },

  /**
   * The auth frame has called navigator.id.cancelAuthentication
   *
   * @param aAuthId
   *        (int)  the identifier of the authentication caller
   *
   */
  cancelAuthentication: function cancelAuthentication(aAuthId) {
    log("cancelAuthentication:", aAuthId);

    // look up the AuthId caller, and get its callback.
    let authFlow = this._authenticationFlows[aAuthId];
    if (!authFlow) {
      reportError("cancelAuthentication", "No auth flow with id:", aAuthId);
      return;
    }
    let provId = authFlow.provId;

    // delete caller
    delete authFlow['caller'];
    delete this._authenticationFlows[aAuthId];

    let provFlow = this.getProvisionFlow(provId);
    provFlow.didAuthentication = true;
    Services.obs.notifyObservers(null, "identity-auth-complete", aAuthId);

    // invoke callback with ERROR.
    let errStr = "Authentication canceled by IDP";
    log("ERROR: cancelAuthentication:", errStr);
    provFlow.callback(errStr);
  },

  /**
   * Called by the UI to set the ID and caller for the authentication flow after it gets its ID
   */
  setAuthenticationFlow: function(aAuthId, aProvId) {
    // this is the transition point between the two flows,
    // provision and authenticate.  We tell the auth flow which
    // provisioning flow it is started from.
    log("setAuthenticationFlow: authId:", aAuthId, "provId:", aProvId);
    this._authenticationFlows[aAuthId] = { provId: aProvId };
    this._provisionFlows[aProvId].authId = aAuthId;
  },

  /**
   * Load the provisioning URL in a hidden frame to start the provisioning
   * process.
   */
  _createProvisioningSandbox: function _createProvisioningSandbox(aURL, aCallback) {
    log("_createProvisioningSandbox:", aURL);

    if (!this._sandboxConfigured) {
      // Configure message manager listening on the hidden window
      Cu.import("resource://gre/modules/DOMIdentity.jsm");
      DOMIdentity._configureMessages(Services.appShell.hiddenDOMWindow, true);
      this._sandboxConfigured = true;
    }

    new Sandbox(aURL, aCallback);
  },

  /**
   * Load the authentication UI to start the authentication process.
   */
  _beginAuthenticationFlow: function _beginAuthenticationFlow(aProvId, aURL) {
    log("_beginAuthenticationFlow:", aProvId, aURL);
    let propBag = {provId: aProvId};

    Services.obs.notifyObservers({wrappedJSObject:propBag}, "identity-auth", aURL);
  },

  /**
   * Clean up a provision flow and the authentication flow and sandbox
   * that may be attached to it.
   */
  _cleanUpProvisionFlow: function _cleanUpProvisionFlow(aProvId) {
    log('_cleanUpProvisionFlow:', aProvId);
    let prov = this._provisionFlows[aProvId];

    // Clean up the sandbox, if there is one.
    if (prov.provisioningSandbox) {
      let sandbox = this._provisionFlows[aProvId]['provisioningSandbox'];
      if (sandbox.free) {
        log('_cleanUpProvisionFlow: freeing sandbox');
        sandbox.free();
      }
      delete this._provisionFlows[aProvId]['provisioningSandbox'];
    }

    // Clean up a related authentication flow, if there is one.
    if (this._authenticationFlows[prov.authId]) {
      delete this._authenticationFlows[prov.authId];
    }

    // Finally delete the provision flow
    delete this._provisionFlows[aProvId];
  }

};

this.IdentityProvider = new IdentityProviderService();
