Bug 1028398 - FxA will silently provide user's email to privileged apps in 2.0. Part 2: Trigger forceAuth when new privileged app tries to get a FxA assertion. r=jedp, a=2.0+
authorFernando Jiménez <ferjmoreno@gmail.com>
Fri, 11 Jul 2014 16:13:32 +0200
changeset 208974 76080ed1f8debe59488647f45c76632c09f500ab
parent 208973 5525d9118f72043c073b8e45eb9e0d2145f7eef6
child 208975 5de9080323053eda62e7a04b03e47a5279b9e881
push id494
push userraliiev@mozilla.com
push dateMon, 25 Aug 2014 18:42:16 +0000
treeherdermozilla-release@a3cc3e46b571 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjedp, 2
bugs1028398
milestone32.0a2
Bug 1028398 - FxA will silently provide user's email to privileged apps in 2.0. Part 2: Trigger forceAuth when new privileged app tries to get a FxA assertion. r=jedp, a=2.0+
dom/identity/DOMIdentity.jsm
services/fxaccounts/FxAccountsCommon.js
services/fxaccounts/FxAccountsManager.jsm
toolkit/identity/FirefoxAccounts.jsm
--- a/dom/identity/DOMIdentity.jsm
+++ b/dom/identity/DOMIdentity.jsm
@@ -98,24 +98,26 @@ IDPAuthenticationContext.prototype = {
                               message);
   },
 
   doError: function IDPAC_doError(msg) {
     log("Authentication ERROR: " + msg);
   }
 };
 
-function RPWatchContext(aOptions, aTargetMM) {
+function RPWatchContext(aOptions, aTargetMM, aPrincipal) {
   objectCopy(aOptions, this);
 
   // id and origin are required
   if (! (this.id && this.origin)) {
     throw new Error("id and origin are required for RP watch context");
   }
 
+  this.principal = aPrincipal;
+
   // default for no loggedInUser is undefined, not null
   this.loggedInUser = aOptions.loggedInUser;
 
   // Maybe internal.  For hosted b2g identity shim.
   this._internal = aOptions._internal;
 
   this._mm = aTargetMM;
 }
@@ -182,18 +184,18 @@ this.DOMIdentity = {
       return this._mockIdentityService;
     }
     return IdentityService;
   },
 
   /*
    * Create a new RPWatchContext, and update the context maps.
    */
-  newContext: function(message, targetMM) {
-    let context = new RPWatchContext(message, targetMM);
+  newContext: function(message, targetMM, principal) {
+    let context = new RPWatchContext(message, targetMM, principal);
     this._serviceContexts.set(message.id, context);
     this._mmContexts.set(targetMM, message.id);
     return context;
   },
 
   /*
    * Get the identity service used for an RP.
    *
@@ -271,26 +273,26 @@ this.DOMIdentity = {
 
     if (!this.hasPermission(aMessage)) {
       throw new Error("PERMISSION_DENIED");
     }
 
     switch (aMessage.name) {
       // RP
       case "Identity:RP:Watch":
-        this._watch(msg, targetMM);
+        this._watch(msg, targetMM, aMessage.principal);
         break;
       case "Identity:RP:Unwatch":
         this._unwatch(msg, targetMM);
         break;
       case "Identity:RP:Request":
-        this._request(msg, targetMM);
+        this._request(msg);
         break;
       case "Identity:RP:Logout":
-        this._logout(msg, targetMM);
+        this._logout(msg);
         break;
       // IDP
       case "Identity:IDP:BeginProvisioning":
         this._beginProvisioning(msg, targetMM);
         break;
       case "Identity:IDP:GenKeyPair":
         this._genKeyPair(msg);
         break;
@@ -354,19 +356,19 @@ this.DOMIdentity = {
 
   _unsubscribeListeners: function DOMIdentity__unsubscribeListeners() {
     for (let message of this.messages) {
       ppmm.removeMessageListener(message, this);
     }
     ppmm = null;
   },
 
-  _watch: function DOMIdentity__watch(message, targetMM) {
-    log("DOMIdentity__watch: " + message.id);
-    let context = this.newContext(message, targetMM);
+  _watch: function DOMIdentity__watch(message, targetMM, principal) {
+    log("DOMIdentity__watch: " + message.id + " - " + principal);
+    let context = this.newContext(message, targetMM, principal);
     this.getService(message).RP.watch(context);
   },
 
   _unwatch: function DOMIdentity_unwatch(message, targetMM) {
     log("DOMIDentity__unwatch: " + message.id);
     // If watch failed for some reason (e.g., exception thrown because RP did
     // not have the right callbacks, we don't want unwatch to throw, because it
     // will break the process of releasing the page's resources and leak
--- a/services/fxaccounts/FxAccountsCommon.js
+++ b/services/fxaccounts/FxAccountsCommon.js
@@ -55,16 +55,18 @@ XPCOMUtils.defineLazyGetter(this, 'log',
 XPCOMUtils.defineLazyGetter(this, 'logPII', function() {
   try {
     return Services.prefs.getBoolPref(PREF_LOG_SENSITIVE_DETAILS);
   } catch (_) {
     return false;
   }
 });
 
+this.FXACCOUNTS_PERMISSION = "firefox-accounts";
+
 this.DATA_FORMAT_VERSION = 1;
 this.DEFAULT_STORAGE_FILENAME = "signedInUser.json";
 
 // Token life times.
 // Having this parameter be short has limited security value and can cause
 // spurious authentication values if the client's clock is skewed and
 // we fail to adjust. See Bug 983256.
 this.ASSERTION_LIFETIME = 1000 * 3600 * 24 * 365 * 25; // 25 years
@@ -136,16 +138,17 @@ this.ERROR_INVALID_REFRESH_AUTH_VALUE   
 this.ERROR_INVALID_REQUEST_SIGNATURE      = "INVALID_REQUEST_SIGNATURE";
 this.ERROR_INTERNAL_INVALID_USER          = "INTERNAL_ERROR_INVALID_USER";
 this.ERROR_MISSING_BODY_PARAMETERS        = "MISSING_BODY_PARAMETERS";
 this.ERROR_MISSING_CONTENT_LENGTH         = "MISSING_CONTENT_LENGTH";
 this.ERROR_NO_TOKEN_SESSION               = "NO_TOKEN_SESSION";
 this.ERROR_NO_SILENT_REFRESH_AUTH         = "NO_SILENT_REFRESH_AUTH";
 this.ERROR_NOT_VALID_JSON_BODY            = "NOT_VALID_JSON_BODY";
 this.ERROR_OFFLINE                        = "OFFLINE";
+this.ERROR_PERMISSION_DENIED              = "PERMISSION_DENIED";
 this.ERROR_REQUEST_BODY_TOO_LARGE         = "REQUEST_BODY_TOO_LARGE";
 this.ERROR_SERVER_ERROR                   = "SERVER_ERROR";
 this.ERROR_TOO_MANY_CLIENT_REQUESTS       = "TOO_MANY_CLIENT_REQUESTS";
 this.ERROR_SERVICE_TEMP_UNAVAILABLE       = "SERVICE_TEMPORARY_UNAVAILABLE";
 this.ERROR_UI_ERROR                       = "UI_ERROR";
 this.ERROR_UI_REQUEST                     = "UI_REQUEST";
 this.ERROR_UNKNOWN                        = "UNKNOWN_ERROR";
 this.ERROR_UNVERIFIED_ACCOUNT             = "UNVERIFIED_ACCOUNT";
--- a/services/fxaccounts/FxAccountsManager.jsm
+++ b/services/fxaccounts/FxAccountsManager.jsm
@@ -16,16 +16,20 @@ this.EXPORTED_SYMBOLS = ["FxAccountsMana
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/FxAccounts.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/FxAccountsCommon.js");
 
+XPCOMUtils.defineLazyServiceGetter(this, "permissionManager",
+                                   "@mozilla.org/permissionmanager;1",
+                                   "nsIPermissionManager");
+
 this.FxAccountsManager = {
 
   init: function() {
     Services.obs.addObserver(this, ONLOGOUT_NOTIFICATION, false);
     Services.obs.addObserver(this, ON_FXA_UPDATE_NOTIFICATION, false);
   },
 
   observe: function(aSubject, aTopic, aData) {
@@ -170,73 +174,82 @@ this.FxAccountsManager = {
    *
    * As of May 2014, the only HTTP call triggered by this._getAssertion()
    * is to /certificate/sign via:
    *   FxAccounts.getAssertion()
    *     FxAccountsInternal.getCertificateSigned()
    *       FxAccountsClient.signCertificate()
    * See the latter method for possible (error code, errno) pairs.
    */
-  _handleGetAssertionError: function(reason, aAudience) {
+  _handleGetAssertionError: function(reason, aAudience, aPrincipal) {
     let errno = (reason ? reason.errno : NaN) || NaN;
     // If the previously valid email/password pair is no longer valid ...
     if (errno == ERRNO_INVALID_AUTH_TOKEN) {
       return this._fxAccounts.accountStatus().then(
         (exists) => {
           // ... if the email still maps to an account, the password
           // must have changed, so ask the user to enter the new one ...
           if (exists) {
             return this.getAccount().then(
               (user) => {
-                return this._refreshAuthentication(aAudience, user.email, true);
-              }
-            );
-          // ... otherwise, the account was deleted, so ask for Sign In/Up
-          } else {
-            return this._localSignOut().then(
-              () => {
-                return this._uiRequest(UI_REQUEST_SIGN_IN_FLOW, aAudience);
-              },
-              (reason) => { // reject primary problem, not signout failure
-                log.error("Signing out in response to server error threw: " + reason);
-                return this._error(reason);
+                return this._refreshAuthentication(aAudience, user.email,
+                                                   aPrincipal,
+                                                   true /* logoutOnFailure */);
               }
             );
           }
         }
       );
+
+      // Otherwise, the account was deleted, so ask for Sign In/Up
+      return this._localSignOut().then(
+        () => {
+          return this._uiRequest(UI_REQUEST_SIGN_IN_FLOW, aAudience,
+                                 aPrincipal);
+        },
+        (reason) => {
+          // reject primary problem, not signout failure
+          log.error("Signing out in response to server error threw: " +
+                    reason);
+          return this._error(reason);
+        }
+      );
     }
     return Promise.reject(reason);
   },
 
-  _getAssertion: function(aAudience) {
+  _getAssertion: function(aAudience, aPrincipal) {
     return this._fxAccounts.getAssertion(aAudience).then(
       (result) => {
+        if (aPrincipal) {
+          this._addPermission(aPrincipal);
+        }
         return result;
       },
       (reason) => {
-        return this._handleGetAssertionError(reason, aAudience);
+        return this._handleGetAssertionError(reason, aAudience, aPrincipal);
       }
     );
   },
 
   /**
    * "Refresh authentication" means:
    *   Interactively demonstrate knowledge of the FxA password
    *   for the currently logged-in account.
    * There are two very different scenarios:
    *   1) The password has changed on the server. Failure should log
    *      the current account OUT.
    *   2) The person typing can't prove knowledge of the password used
    *      to log in. Failure should do nothing.
    */
-  _refreshAuthentication: function(aAudience, aEmail, logoutOnFailure=false) {
+  _refreshAuthentication: function(aAudience, aEmail, aPrincipal,
+                                   logoutOnFailure=false) {
     this._refreshing = true;
     return this._uiRequest(UI_REQUEST_REFRESH_AUTH,
-                           aAudience, aEmail).then(
+                           aAudience, aPrincipal, aEmail).then(
       (assertion) => {
         this._refreshing = false;
         return assertion;
       },
       (reason) => {
         this._refreshing = false;
         if (logoutOnFailure) {
           return this._signOut().then(
@@ -288,45 +301,56 @@ this.FxAccountsManager = {
           reason => {
             return this._serverError(reason);
           }
         );
       }
     );
   },
 
-  _uiRequest: function(aRequest, aAudience, aParams) {
+  _uiRequest: function(aRequest, aAudience, aPrincipal, aParams) {
     let ui = Cc["@mozilla.org/fxaccounts/fxaccounts-ui-glue;1"]
                .createInstance(Ci.nsIFxAccountsUIGlue);
     if (!ui[aRequest]) {
       return this._error(ERROR_UI_REQUEST);
     }
 
     if (!aParams || !Array.isArray(aParams)) {
       aParams = [aParams];
     }
 
     return ui[aRequest].apply(this, aParams).then(
       result => {
         // Even if we get a successful result from the UI, the account will
         // most likely be unverified, so we cannot get an assertion.
         if (result && result.verified) {
-          return this._getAssertion(aAudience);
+          return this._getAssertion(aAudience, aPrincipal);
         }
 
         return this._error(ERROR_UNVERIFIED_ACCOUNT, {
           user: result
         });
       },
       error => {
         return this._error(ERROR_UI_ERROR, error);
       }
     );
   },
 
+  _addPermission: function(aPrincipal) {
+    // This will fail from tests cause we are running them in the child
+    // process until we have chrome tests in b2g. Bug 797164.
+    try {
+      permissionManager.addFromPrincipal(aPrincipal, FXACCOUNTS_PERMISSION,
+                                         Ci.nsIPermissionManager.ALLOW_ACTION);
+    } catch (e) {
+      log.warn("Could not add permission " + e);
+    }
+  },
+
   // -- API --
 
   signIn: function(aEmail, aPassword) {
     return this._signInSignUp("signIn", aEmail, aPassword);
   },
 
   signUp: function(aEmail, aPassword) {
     return this._signInSignUp("signUp", aEmail, aPassword);
@@ -464,32 +488,41 @@ this.FxAccountsManager = {
    * the heart of the response to navigator.mozId.request() on device.
    * (We can also be called via the IAC API, but it's request() that
    * makes this method complex.) The state machine looks like this,
    * ignoring simple errors:
    *   If no one is signed in, and we aren't suppressing the UI:
    *     trigger the sign in flow.
    *   else if we were asked to refresh and the grace period is up:
    *     trigger the refresh flow.
-   *   else ask the core code for an assertion, which might itself
-   *   trigger either the sign in or refresh flows (if our account
-   *   changed on the server).
+   *   else:
+   *      request user permission to share an assertion if we don't have it
+   *      already and ask the core code for an assertion, which might itself
+   *      trigger either the sign in or refresh flows (if our account
+   *      changed on the server).
    *
    * aOptions can include:
    *   refreshAuthentication  - (bool) Force re-auth.
    *   silent                 - (bool) Prevent any UI interaction.
    *                            I.e., try to get an automatic assertion.
    */
-  getAssertion: function(aAudience, aOptions) {
+  getAssertion: function(aAudience, aPrincipal, aOptions) {
     if (!aAudience) {
       return this._error(ERROR_INVALID_AUDIENCE);
     }
     if (Services.io.offline) {
       return this._error(ERROR_OFFLINE);
     }
+
+    let secMan = Cc["@mozilla.org/scriptsecuritymanager;1"]
+                   .getService(Ci.nsIScriptSecurityManager);
+    let uri = Services.io.newURI(aPrincipal.origin, null, null);
+    let principal = secMan.getAppCodebasePrincipal(uri,
+      aPrincipal.appId, aPrincipal.isInBrowserElement);
+
     return this.getAccount().then(
       user => {
         if (user) {
           // Three have-user cases to consider. First: are we unverified?
           if (!user.verified) {
             return this._error(ERROR_UNVERIFIED_ACCOUNT, {
               user: user
             });
@@ -501,30 +534,51 @@ this.FxAccountsManager = {
             if (typeof(gracePeriod) !== "number" || isNaN(gracePeriod)) {
               return this._error(ERROR_INVALID_REFRESH_AUTH_VALUE);
             }
             // Forcing refreshAuth to silent is a contradiction in terms,
             // though it might succeed silently if we didn't reject here.
             if (aOptions.silent) {
               return this._error(ERROR_NO_SILENT_REFRESH_AUTH);
             }
-            let secondsSinceAuth = (Date.now() / 1000) - this._activeSession.authAt;
+            let secondsSinceAuth = (Date.now() / 1000) -
+                                   this._activeSession.authAt;
             if (secondsSinceAuth > gracePeriod) {
-              return this._refreshAuthentication(aAudience, user.email);
+              return this._refreshAuthentication(aAudience, user.email,
+                                                 principal,
+                                                 false /* logoutOnFailure */);
             }
           }
           // Third case: we are all set *locally*. Probably we just return
           // the assertion, but the attempt might lead to the server saying
           // we are deleted or have a new password, which will trigger a flow.
-          return this._getAssertion(aAudience);
+          // Also we need to check if we have permission to get the assertion,
+          // otherwise we need to show the forceAuth UI to let the user know
+          // that the RP with no fxa permissions is trying to obtain an
+          // assertion. Once the user authenticates herself in the forceAuth UI
+          // the permission will be remembered by default.
+          let permission = permissionManager.testPermissionFromPrincipal(
+            principal,
+            FXACCOUNTS_PERMISSION
+          );
+          if (permission == Ci.nsIPermissionManager.PROMPT_ACTION &&
+              !this._refreshing) {
+            return this._refreshAuthentication(aAudience, user.email,
+                                               principal,
+                                               false /* logoutOnFailure */);
+          } else if (permission == Ci.nsIPermissionManager.DENY_ACTION &&
+                     !this._refreshing) {
+            return this._error(ERROR_PERMISSION_DENIED);
+          }
+          return this._getAssertion(aAudience, principal);
         }
         log.debug("No signed in user");
         if (aOptions && aOptions.silent) {
           return Promise.resolve(null);
         }
-        return this._uiRequest(UI_REQUEST_SIGN_IN_FLOW, aAudience);
+        return this._uiRequest(UI_REQUEST_SIGN_IN_FLOW, aAudience, principal);
       }
     );
   }
 
 };
 
 FxAccountsManager.init();
--- a/toolkit/identity/FirefoxAccounts.jsm
+++ b/toolkit/identity/FirefoxAccounts.jsm
@@ -97,21 +97,26 @@ FxAccountsService.prototype = {
           Services.obs.removeObserver(this, ONVERIFIED_NOTIFICATION);
           Services.obs.removeObserver(this, ONLOGIN_NOTIFICATION);
           Services.obs.removeObserver(this, ONLOGOUT_NOTIFICATION);
         }
         break;
     }
   },
 
+  cleanupRPRequest: function(aRp) {
+    aRp.pendingRequest = false;
+    this._rpFlows.set(aRp.id, aRp);
+  },
+
   /**
    * Register a listener for a given windowID as a result of a call to
    * navigator.id.watch().
    *
-   * @param aCaller
+   * @param aRPCaller
    *        (Object)  an object that represents the caller document, and
    *                  is expected to have properties:
    *                  - id (unique, e.g. uuid)
    *                  - origin (string)
    *
    *                  and a bunch of callbacks
    *                  - doReady()
    *                  - doLogin()
@@ -123,17 +128,19 @@ FxAccountsService.prototype = {
   watch: function watch(aRpCaller) {
     this._rpFlows.set(aRpCaller.id, aRpCaller);
     log.debug("watch: " + aRpCaller.id);
     log.debug("Current rp flows: " + this._rpFlows.size);
 
     // Log the user in, if possible, and then call ready().
     let runnable = {
       run: () => {
-        this.fxAccountsManager.getAssertion(aRpCaller.audience, {silent:true}).then(
+        this.fxAccountsManager.getAssertion(aRpCaller.audience,
+                                            aRpCaller.principal,
+                                            { silent:true }).then(
           data => {
             if (data) {
               this.doLogin(aRpCaller.id, data);
             } else {
               this.doLogout(aRpCaller.id);
             }
             this.doReady(aRpCaller.id);
           },
@@ -156,47 +163,70 @@ FxAccountsService.prototype = {
     this._rpFlows.delete(aRpCallerId);
   },
 
   /**
    * Initiate a login with user interaction as a result of a call to
    * navigator.id.request().
    *
    * @param aRPId
-   *        (integer)  the id of the doc object obtained in .watch()
+   *        (integer) the id of the doc object obtained in .watch()
    *
    * @param aOptions
-   *        (Object)  options including privacyPolicy, termsOfService
+   *        (Object) options including privacyPolicy, termsOfService
    */
   request: function request(aRPId, aOptions) {
     aOptions = aOptions || {};
     let rp = this._rpFlows.get(aRPId);
     if (!rp) {
       log.error("request() called before watch()");
       return;
     }
 
+    // We check if we already have a pending request for this RP and in that
+    // case we just bail out. We don't want duplicated onlogin or oncancel
+    // events.
+    if (rp.pendingRequest) {
+      log.debug("request() already called");
+      return;
+    }
+
+    // Otherwise, we set the RP flow with the pending request flag.
+    rp.pendingRequest = true;
+    this._rpFlows.set(rp.id, rp);
+
     let options = makeMessageObject(rp);
     objectCopy(aOptions, options);
 
     log.debug("get assertion for " + rp.audience);
 
-    this.fxAccountsManager.getAssertion(rp.audience, options).then(
+    this.fxAccountsManager.getAssertion(rp.audience, rp.principal, options)
+    .then(
       data => {
         log.debug("got assertion for " + rp.audience + ": " + data);
         this.doLogin(aRPId, data);
       },
       error => {
         log.error("get assertion failed: " + JSON.stringify(error));
         // Cancellation is passed through an error channel; here we reroute.
         if (error.error && (error.error.details == "DIALOG_CLOSED_BY_USER")) {
           return this.doCancel(aRPId);
         }
         this.doError(aRPId, error);
       }
+    )
+    .then(
+      () => {
+        this.cleanupRPRequest(rp);
+      }
+    )
+    .catch(
+      () => {
+        this.cleanupRPRequest(rp);
+      }
     );
   },
 
   /**
    * Invoked when a user wishes to logout of a site (for instance, when clicking
    * on an in-content logout button).
    *
    * @param aRpCallerId