Bug 1047164 - Handle authentication errors (e.g. token expiry) for FxA Loop sessions and notify users. r=jaws
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Fri, 03 Oct 2014 03:17:32 -0700
changeset 218133 67279088803419327fb649b0bcab681dabc764e2
parent 218132 4fda0b1548612cc3c7f3aa34180b88945e8c2dff
child 218134 ebebf3b4b0bdc3d90aedd44d7fe1c4fa4165a91c
push id2
push usergszorc@mozilla.com
push dateWed, 12 Nov 2014 19:43:22 +0000
treeherderfig@7a5f4d72e05d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjaws
bugs1047164
milestone34.0a2
Bug 1047164 - Handle authentication errors (e.g. token expiry) for FxA Loop sessions and notify users. r=jaws
browser/components/loop/MozLoopAPI.jsm
browser/components/loop/MozLoopService.jsm
browser/components/loop/content/js/client.js
browser/components/loop/content/js/panel.js
browser/components/loop/content/js/panel.jsx
browser/components/loop/content/shared/css/common.css
browser/components/loop/content/shared/js/models.js
browser/components/loop/content/shared/js/views.js
browser/components/loop/content/shared/js/views.jsx
browser/components/loop/test/desktop-local/client_test.js
browser/components/loop/test/desktop-local/panel_test.js
browser/components/loop/test/mochitest/browser_fxa_login.js
browser/components/loop/test/mochitest/head.js
browser/components/loop/test/xpcshell/head.js
browser/components/loop/test/xpcshell/test_loopservice_hawk_errors.js
browser/components/loop/test/xpcshell/xpcshell.ini
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -139,16 +139,38 @@ function injectLoopAPI(targetWindow) {
       get: function() {
         return MozLoopService.doNotDisturb;
       },
       set: function(aFlag) {
         MozLoopService.doNotDisturb = aFlag;
       }
     },
 
+    errors: {
+      enumerable: true,
+      get: function() {
+        let errors = {};
+        for (let [type, error] of MozLoopService.errors) {
+          // if error.error is an nsIException, just delete it since it's hard
+          // to clone across the boundary.
+          if (error.error instanceof Ci.nsIException) {
+            MozLoopService.log.debug("Warning: Some errors were omitted from MozLoopAPI.errors " +
+                                     "due to issues copying nsIException across boundaries.",
+                                     error.error);
+            delete error.error;
+          }
+
+          // We have to clone the error property since it may be an Error object.
+          errors[type] = Cu.cloneInto(error, targetWindow);
+
+        }
+        return Cu.cloneInto(errors, targetWindow);
+      },
+    },
+
     /**
      * Returns the current locale of the browser.
      *
      * @returns {String} The locale string
      */
     locale: {
       enumerable: true,
       get: function() {
@@ -508,19 +530,19 @@ function injectLoopAPI(targetWindow) {
           try {
             appVersionInfo = Cu.cloneInto({
               channel: defaults.getCharPref("app.update.channel"),
               version: appInfo.version,
               OS: appInfo.OS
             }, targetWindow);
           } catch (ex) {
             // only log outside of xpcshell to avoid extra message noise
-            if (typeof window !== 'undefined' && "console" in window) {
-              console.log("Failed to construct appVersionInfo; if this isn't " +
-                          "an xpcshell unit test, something is wrong", ex);
+            if (typeof targetWindow !== 'undefined' && "console" in targetWindow) {
+              MozLoopService.log.error("Failed to construct appVersionInfo; if this isn't " +
+                                       "an xpcshell unit test, something is wrong", ex);
             }
           }
         }
         return appVersionInfo;
       }
     },
 
     /**
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -1,15 +1,15 @@
 /* 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 } = Components;
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 // Invalid auth token as per
 // https://github.com/mozilla-services/loop-server/blob/45787d34108e2f0d87d74d4ddf4ff0dbab23501c/loop/errno.json#L6
 const INVALID_AUTH_TOKEN = 110;
 
 // Ticket numbers are 24 bits in length.
 // The highest valid ticket number is 16777214 (2^24 - 2), so that a "now
 // serving" number of 2^24 - 1 is greater than it.
@@ -325,21 +325,70 @@ let MozLoopServiceInternal = {
   },
 
   notifyStatusChanged: function(aReason = null) {
     log.debug("notifyStatusChanged with reason:", aReason);
     Services.obs.notifyObservers(null, "loop-status-changed", aReason);
   },
 
   /**
+   * Record an error and notify interested UI with the relevant user-facing strings attached.
+   *
    * @param {String} errorType a key to identify the type of error. Only one
-   *                           error of a type will be saved at a time.
+   *                           error of a type will be saved at a time. This value may be used to
+   *                           determine user-facing (aka. friendly) strings.
    * @param {Object} error     an object describing the error in the format from Hawk errors
    */
   setError: function(errorType, error) {
+    let messageString, detailsString, detailsButtonLabelString;
+    const NETWORK_ERRORS = [
+      Cr.NS_ERROR_CONNECTION_REFUSED,
+      Cr.NS_ERROR_NET_INTERRUPT,
+      Cr.NS_ERROR_NET_RESET,
+      Cr.NS_ERROR_NET_TIMEOUT,
+      Cr.NS_ERROR_OFFLINE,
+      Cr.NS_ERROR_PROXY_CONNECTION_REFUSED,
+      Cr.NS_ERROR_UNKNOWN_HOST,
+      Cr.NS_ERROR_UNKNOWN_PROXY_HOST,
+    ];
+
+    if (error.code === null && error.errno === null &&
+        error.error instanceof Ci.nsIException &&
+        NETWORK_ERRORS.indexOf(error.error.result) != -1) {
+      // Network error. Override errorType so we can easily clear it on the next succesful request.
+      errorType = "network";
+      messageString = "could_not_connect";
+      detailsString = "check_internet_connection";
+      detailsButtonLabelString = "retry_button";
+    } else if (errorType == "profile" && error.code >= 500 && error.code < 600) {
+      messageString = "problem_accessing_account";
+    } else if (error.code == 401) {
+      if (errorType == "login") {
+        messageString = "could_not_authenticate"; // XXX: Bug 1076377
+        detailsString = "password_changed_question";
+        detailsButtonLabelString = "retry_button";
+      } else {
+        messageString = "session_expired_error_description";
+      }
+    } else if (error.code >= 500 && error.code < 600) {
+      messageString = "service_not_available";
+      detailsString = "try_again_later";
+      detailsButtonLabelString = "retry_button";
+    } else {
+      messageString = "generic_failure_title";
+    }
+
+    error.friendlyMessage = this.localizedStrings[messageString].textContent;
+    error.friendlyDetails = detailsString ?
+                              this.localizedStrings[detailsString].textContent :
+                              null;
+    error.friendlyDetailsButtonLabel = detailsButtonLabelString ?
+                                         this.localizedStrings[detailsButtonLabelString].textContent :
+                                         null;
+
     gErrors.set(errorType, error);
     this.notifyStatusChanged();
   },
 
   clearError: function(errorType) {
     gErrors.delete(errorType);
     this.notifyStatusChanged();
   },
@@ -406,17 +455,40 @@ let MozLoopServiceInternal = {
 
     let credentials;
     if (sessionToken) {
       // true = use a hex key, as required by the server (see bug 1032738).
       credentials = deriveHawkCredentials(sessionToken, "sessionToken",
                                           2 * 32, true);
     }
 
-    return gHawkClient.request(path, method, credentials, payloadObj);
+    return gHawkClient.request(path, method, credentials, payloadObj).then((result) => {
+      this.clearError("network");
+      return result;
+    }, (error) => {
+      if (error.code == 401) {
+        this.clearSessionToken(sessionType);
+
+        if (sessionType == LOOP_SESSION_TYPE.FXA) {
+          MozLoopService.logOutFromFxA().then(() => {
+            // Set a user-visible error after logOutFromFxA clears existing ones.
+            this.setError("login", error);
+          });
+        } else {
+          if (!this.urlExpiryTimeIsInFuture()) {
+            // If there are no Guest URLs in the future, don't use setError to notify the user since
+            // there isn't a need for a Guest registration at this time.
+            throw error;
+          }
+
+          this.setError("registration", error);
+        }
+      }
+      throw error;
+    });
   },
 
   /**
    * Generic hawkRequest onError handler for the hawkRequest promise.
    *
    * @param {Object} error - error reporting object
    *
    */
@@ -529,31 +601,23 @@ let MozLoopServiceInternal = {
         if (!this.storeSessionToken(sessionType, response.headers))
           return;
 
         log.debug("Successfully registered with server for sessionType", sessionType);
         this.clearError("registration");
       }, (error) => {
         // There's other errors than invalid auth token, but we should only do the reset
         // as a last resort.
-        if (error.code === 401 && error.errno === INVALID_AUTH_TOKEN) {
-          if (this.urlExpiryTimeIsInFuture()) {
-            // XXX Should this be reported to the user is a visible manner?
-            Cu.reportError("Loop session token is invalid, all previously "
-                           + "generated urls will no longer work.");
-          }
-
+        if (error.code === 401) {
           // Authorization failed, invalid token, we need to try again with a new token.
-          this.clearSessionToken(sessionType);
           if (retry) {
             return this.registerWithLoopServer(sessionType, pushUrl, false);
           }
         }
 
-        // XXX Bubble the precise details up to the UI somehow (bug 1013248).
         log.error("Failed to register with the loop server. Error: ", error);
         this.setError("registration", error);
         throw error;
       }
     );
   },
 
   /**
@@ -563,26 +627,31 @@ let MozLoopServiceInternal = {
    * guest session with the device.
    *
    * @param {LOOP_SESSION_TYPE} sessionType The type of session e.g. guest or FxA
    * @param {String} pushURL The push URL previously given by the push server.
    *                         This may not be necessary to unregister in the future.
    * @return {Promise} resolving when the unregistration request finishes
    */
   unregisterFromLoopServer: function(sessionType, pushURL) {
+    let prefType = Services.prefs.getPrefType(this.getSessionTokenPrefName(sessionType));
+    if (prefType == Services.prefs.PREF_INVALID) {
+      return Promise.resolve("already unregistered");
+    }
+
     let unregisterURL = "/registration?simplePushURL=" + encodeURIComponent(pushURL);
     return this.hawkRequest(sessionType, unregisterURL, "DELETE")
       .then(() => {
         log.debug("Successfully unregistered from server for sessionType", sessionType);
         MozLoopServiceInternal.clearSessionToken(sessionType);
       },
       error => {
         // Always clear the registration token regardless of whether the server acknowledges the logout.
         MozLoopServiceInternal.clearSessionToken(sessionType);
-        if (error.code === 401 && error.errno === INVALID_AUTH_TOKEN) {
+        if (error.code === 401) {
           // Authorization failed, invalid token. This is fine since it may mean we already logged out.
           return;
         }
 
         log.error("Failed to unregister with the loop server. Error: ", error);
         throw error;
       });
   },
@@ -1112,16 +1181,17 @@ this.MozLoopService = {
    * with the Loop server. It will return early if already registered.
    *
    * @param {Object} mockPushHandler Optional, test-only mock push handler. Used
    *                                 to allow mocking of the MozLoopPushHandler.
    * @returns {Promise} a promise that is resolved with no params on completion, or
    *          rejected with an error code or string.
    */
   register: function(mockPushHandler, mockWebSocket) {
+    log.debug("registering");
     // Don't do anything if loop is not enabled.
     if (!Services.prefs.getBoolPref("loop.enabled")) {
       throw new Error("Loop is not enabled");
     }
 
     if (Services.prefs.getBoolPref("loop.throttled")) {
       throw new Error("Loop is disabled by the soft-start mechanism");
     }
@@ -1191,16 +1261,20 @@ this.MozLoopService = {
   get userProfile() {
     return gFxAOAuthProfile;
   },
 
   get errors() {
     return MozLoopServiceInternal.errors;
   },
 
+  get log() {
+    return log;
+  },
+
   /**
    * Returns the current locale
    *
    * @return {String} The code of the current locale.
    */
   get locale() {
     try {
       return Services.prefs.getComplexValue("general.useragent.locale",
@@ -1326,62 +1400,75 @@ this.MozLoopService = {
       return tokenData;
     }).then(tokenData => {
       return gRegisteredDeferred.promise.then(Task.async(function*() {
         if (gPushHandler.pushUrl) {
           yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, gPushHandler.pushUrl);
         } else {
           throw new Error("No pushUrl for FxA registration");
         }
+        MozLoopServiceInternal.clearError("login");
+        MozLoopServiceInternal.clearError("profile");
         return gFxAOAuthTokenData;
       }));
     }).then(tokenData => {
       let client = new FxAccountsProfileClient({
         serverURL: gFxAOAuthClient.parameters.profile_uri,
         token: tokenData.access_token
       });
       client.fetchProfile().then(result => {
         gFxAOAuthProfile = result;
         MozLoopServiceInternal.notifyStatusChanged("login");
       }, error => {
         log.error("Failed to retrieve profile", error);
+        this.setError("profile", error);
         gFxAOAuthProfile = null;
         MozLoopServiceInternal.notifyStatusChanged();
       });
       return tokenData;
     }).catch(error => {
       gFxAOAuthTokenData = null;
       gFxAOAuthProfile = null;
       throw error;
+    }).catch((error) => {
+      MozLoopServiceInternal.setError("login", error);
+      // Re-throw for testing
+      throw error;
     });
   },
 
   /**
    * Logs the user out from FxA.
    *
    * Gracefully handles if the user is already logged out.
    *
    * @return {Promise} that resolves when the FxA logout flow is complete.
    */
   logOutFromFxA: Task.async(function*() {
     log.debug("logOutFromFxA");
-    yield MozLoopServiceInternal.unregisterFromLoopServer(LOOP_SESSION_TYPE.FXA,
-                                                          gPushHandler.pushUrl);
+    if (gPushHandler && gPushHandler.pushUrl) {
+      yield MozLoopServiceInternal.unregisterFromLoopServer(LOOP_SESSION_TYPE.FXA,
+                                                            gPushHandler.pushUrl);
+    } else {
+      MozLoopServiceInternal.clearSessionToken(LOOP_SESSION_TYPE.FXA);
+    }
 
     gFxAOAuthTokenData = null;
     gFxAOAuthProfile = null;
 
     // Reset the client since the initial promiseFxAOAuthParameters() call is
     // what creates a new session.
     gFxAOAuthClient = null;
     gFxAOAuthClientPromise = null;
 
     // clearError calls notifyStatusChanged so should be done last when the
     // state is clean.
     MozLoopServiceInternal.clearError("registration");
+    MozLoopServiceInternal.clearError("login");
+    MozLoopServiceInternal.clearError("profile");
   }),
 
   openFxASettings: function() {
     let url = new URL("/settings", gFxAOAuthClient.parameters.content_uri);
     let win = Services.wm.getMostRecentWindow("navigator:browser");
     win.switchToTabHavingURI(url.toString(), true);
   },
 
--- a/browser/components/loop/content/js/client.js
+++ b/browser/components/loop/content/js/client.js
@@ -73,17 +73,17 @@ loop.Client = (function($) {
      * Generic handler for XHR failures.
      *
      * @param {Function} cb Callback(err)
      * @param {Object} error See MozLoopAPI.hawkRequest
      */
     _failureHandler: function(cb, error) {
       var message = "HTTP " + error.code + " " + error.error + "; " + error.message;
       console.error(message);
-      cb(new Error(message));
+      cb(error);
     },
 
     /**
      * Ensures the client is registered with the push server.
      *
      * Callback parameters:
      * - err null on successful registration, non-null otherwise.
      *
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -307,20 +307,22 @@ loop.panel = (function(_, mozL10n) {
      */
     _fetchCallUrl: function() {
       this.setState({pending: true});
       this.props.client.requestCallUrl(this.conversationIdentifier(),
                                        this._onCallUrlReceived);
     },
 
     _onCallUrlReceived: function(err, callUrlData) {
-      this.props.notifications.reset();
-
       if (err) {
-        this.props.notifications.errorL10n("unable_retrieve_url");
+        if (err.code != 401) {
+          // 401 errors are already handled in hawkRequest and show an error
+          // message about the session.
+          this.props.notifications.errorL10n("unable_retrieve_url");
+        }
         this.setState(this.getInitialState());
       } else {
         try {
           var callUrl = new window.URL(callUrlData.callUrl);
           // XXX the current server vers does not implement the callToken field
           // but it exists in the API. This workaround should be removed in the future
           var token = callUrlData.callToken ||
                       callUrl.pathname.split('/').pop();
@@ -440,35 +442,67 @@ loop.panel = (function(_, mozL10n) {
     },
 
     getInitialState: function() {
       return {
         userProfile: this.props.userProfile || navigator.mozLoop.userProfile,
       };
     },
 
-    _onAuthStatusChange: function() {
+    _serviceErrorToShow: function() {
+      if (!navigator.mozLoop.errors || !Object.keys(navigator.mozLoop.errors).length) {
+        return null;
+      }
+      // Just get the first error for now since more than one should be rare.
+      var firstErrorKey = Object.keys(navigator.mozLoop.errors)[0];
+      return {
+        type: firstErrorKey,
+        error: navigator.mozLoop.errors[firstErrorKey],
+      };
+    },
+
+    updateServiceErrors: function() {
+      var serviceError = this._serviceErrorToShow();
+      if (serviceError) {
+        this.props.notifications.set({
+          id: "service-error",
+          level: "error",
+          message: serviceError.error.friendlyMessage,
+          details: serviceError.error.friendlyDetails,
+          detailsButtonLabel: serviceError.error.friendlyDetailsButtonLabel,
+        });
+      } else {
+        this.props.notifications.remove(this.props.notifications.get("service-error"));
+      }
+    },
+
+    _onStatusChanged: function() {
       this.setState({userProfile: navigator.mozLoop.userProfile});
+      this.updateServiceErrors();
     },
 
     startForm: function(name, contact) {
       this.refs[name].initForm(contact);
       this.selectTab(name);
     },
 
     selectTab: function(name) {
       this.refs.tabView.setState({ selectedTab: name });
     },
 
+    componentWillMount: function() {
+      this.updateServiceErrors();
+    },
+
     componentDidMount: function() {
-      window.addEventListener("LoopStatusChanged", this._onAuthStatusChange);
+      window.addEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
     componentWillUnmount: function() {
-      window.removeEventListener("LoopStatusChanged", this._onAuthStatusChange);
+      window.removeEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
     render: function() {
       var NotificationListView = sharedViews.NotificationListView;
       var displayName = this.state.userProfile && this.state.userProfile.email ||
                         __("display_name_guest");
       return (
         React.DOM.div(null, 
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -307,20 +307,22 @@ loop.panel = (function(_, mozL10n) {
      */
     _fetchCallUrl: function() {
       this.setState({pending: true});
       this.props.client.requestCallUrl(this.conversationIdentifier(),
                                        this._onCallUrlReceived);
     },
 
     _onCallUrlReceived: function(err, callUrlData) {
-      this.props.notifications.reset();
-
       if (err) {
-        this.props.notifications.errorL10n("unable_retrieve_url");
+        if (err.code != 401) {
+          // 401 errors are already handled in hawkRequest and show an error
+          // message about the session.
+          this.props.notifications.errorL10n("unable_retrieve_url");
+        }
         this.setState(this.getInitialState());
       } else {
         try {
           var callUrl = new window.URL(callUrlData.callUrl);
           // XXX the current server vers does not implement the callToken field
           // but it exists in the API. This workaround should be removed in the future
           var token = callUrlData.callToken ||
                       callUrl.pathname.split('/').pop();
@@ -440,35 +442,67 @@ loop.panel = (function(_, mozL10n) {
     },
 
     getInitialState: function() {
       return {
         userProfile: this.props.userProfile || navigator.mozLoop.userProfile,
       };
     },
 
-    _onAuthStatusChange: function() {
+    _serviceErrorToShow: function() {
+      if (!navigator.mozLoop.errors || !Object.keys(navigator.mozLoop.errors).length) {
+        return null;
+      }
+      // Just get the first error for now since more than one should be rare.
+      var firstErrorKey = Object.keys(navigator.mozLoop.errors)[0];
+      return {
+        type: firstErrorKey,
+        error: navigator.mozLoop.errors[firstErrorKey],
+      };
+    },
+
+    updateServiceErrors: function() {
+      var serviceError = this._serviceErrorToShow();
+      if (serviceError) {
+        this.props.notifications.set({
+          id: "service-error",
+          level: "error",
+          message: serviceError.error.friendlyMessage,
+          details: serviceError.error.friendlyDetails,
+          detailsButtonLabel: serviceError.error.friendlyDetailsButtonLabel,
+        });
+      } else {
+        this.props.notifications.remove(this.props.notifications.get("service-error"));
+      }
+    },
+
+    _onStatusChanged: function() {
       this.setState({userProfile: navigator.mozLoop.userProfile});
+      this.updateServiceErrors();
     },
 
     startForm: function(name, contact) {
       this.refs[name].initForm(contact);
       this.selectTab(name);
     },
 
     selectTab: function(name) {
       this.refs.tabView.setState({ selectedTab: name });
     },
 
+    componentWillMount: function() {
+      this.updateServiceErrors();
+    },
+
     componentDidMount: function() {
-      window.addEventListener("LoopStatusChanged", this._onAuthStatusChange);
+      window.addEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
     componentWillUnmount: function() {
-      window.removeEventListener("LoopStatusChanged", this._onAuthStatusChange);
+      window.removeEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
     render: function() {
       var NotificationListView = sharedViews.NotificationListView;
       var displayName = this.state.userProfile && this.state.userProfile.email ||
                         __("display_name_guest");
       return (
         <div>
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -93,16 +93,17 @@ p {
   white-space: nowrap;
   font-size: .9em;
   cursor: pointer;
 }
 
 .btn-info {
   background-color: #0096dd;
   border: 1px solid #0095dd;
+  color: #fff;
 }
 
   .btn-info:hover {
     background-color: #008acb;
     border: 1px solid #008acb;
   }
 
   .btn-info:active {
@@ -224,22 +225,30 @@ p {
 
 .btn-chevron-menu-group .btn {
   flex: 1;
   border-radius: 2px;
   border-bottom-right-radius: 0;
   border-top-right-radius: 0;
 }
 
-/* Alerts */
+/* Alerts/Notifications */
+.notificationContainer {
+  border-bottom: 2px solid #E9E9E9;
+  margin-bottom: 1em;
+}
+
+.messages > .notificationContainer > .alert {
+  text-align: center;
+}
+
+.notificationContainer > .detailsBar,
 .alert {
   background: #eee;
   padding: .4em 1em;
-  margin-bottom: 1em;
-  border-bottom: 2px solid #E9E9E9;
 }
 
 .alert p.message {
   padding: 0;
   margin: 0;
 }
 
 .alert-error {
@@ -247,16 +256,21 @@ p {
   color: #fff;
 }
 
 .alert-warning {
   background: #fcf8e3;
   border: 1px solid #fbeed5;
 }
 
+.notificationContainer > .details-error {
+  background: #fbebeb;
+  color: #d74345
+}
+
 .alert .close {
   position: relative;
   top: -.1rem;
   right: -1rem;
 }
 
 /* Misc */
 
--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -327,16 +327,18 @@ loop.shared.models = (function(l10n) {
     },
   });
 
   /**
    * Notification model.
    */
   var NotificationModel = Backbone.Model.extend({
     defaults: {
+      details: "",
+      detailsButtonLabel: "",
       level: "info",
       message: ""
     }
   });
 
   /**
    * Notification collection
    */
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -612,19 +612,29 @@ loop.shared.views = (function(_, OT, l10
     propTypes: {
       notification: React.PropTypes.object.isRequired,
       key: React.PropTypes.number.isRequired
     },
 
     render: function() {
       var notification = this.props.notification;
       return (
-        React.DOM.div({key: this.props.key, 
-             className: "alert alert-" + notification.get("level")}, 
-          React.DOM.span({className: "message"}, notification.get("message"))
+        React.DOM.div({className: "notificationContainer"}, 
+          React.DOM.div({key: this.props.key, 
+               className: "alert alert-" + notification.get("level")}, 
+            React.DOM.span({className: "message"}, notification.get("message"))
+          ), 
+          React.DOM.div({className: "detailsBar details-" + notification.get("level"), 
+               hidden: !notification.get("details")}, 
+            React.DOM.button({className: "detailsButton btn-info", 
+                    hidden: true || !notification.get("detailsButtonLabel")}, 
+              notification.get("detailsButtonLabel")
+            ), 
+            React.DOM.span({className: "details"}, notification.get("details"))
+          )
         )
       );
     }
   });
 
   /**
    * Notification list view.
    */
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -612,19 +612,29 @@ loop.shared.views = (function(_, OT, l10
     propTypes: {
       notification: React.PropTypes.object.isRequired,
       key: React.PropTypes.number.isRequired
     },
 
     render: function() {
       var notification = this.props.notification;
       return (
-        <div key={this.props.key}
-             className={"alert alert-" + notification.get("level")}>
-          <span className="message">{notification.get("message")}</span>
+        <div className="notificationContainer">
+          <div key={this.props.key}
+               className={"alert alert-" + notification.get("level")}>
+            <span className="message">{notification.get("message")}</span>
+          </div>
+          <div className={"detailsBar details-" + notification.get("level")}
+               hidden={!notification.get("details")}>
+            <button className="detailsButton btn-info"
+                    hidden={true || !notification.get("detailsButtonLabel")}>
+              {notification.get("detailsButtonLabel")}
+            </button>
+            <span className="details">{notification.get("details")}</span>
+          </div>
         </div>
       );
     }
   });
 
   /**
    * Notification list view.
    */
--- a/browser/components/loop/test/desktop-local/client_test.js
+++ b/browser/components/loop/test/desktop-local/client_test.js
@@ -95,17 +95,17 @@ describe("loop.Client", function() {
         // Sets up the hawkRequest stub to trigger the callback with
         // an error
         hawkRequestStub.callsArgWith(4, fakeErrorRes);
 
         client.deleteCallUrl(fakeToken, callback);
 
         sinon.assert.calledOnce(callback);
         sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
-          return /400.*invalid token/.test(err.message);
+          return err.code == 400 && "invalid token" == err.message;
         }));
       });
     });
 
     describe("#requestCallUrl", function() {
       it("should ensure loop is registered", function() {
         client.requestCallUrl("foo", callback);
 
@@ -213,17 +213,17 @@ describe("loop.Client", function() {
         // Sets up the hawkRequest stub to trigger the callback with
         // an error
         hawkRequestStub.callsArgWith(4, fakeErrorRes);
 
         client.requestCallUrl("foo", callback);
 
         sinon.assert.calledOnce(callback);
         sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
-          return /400.*invalid token/.test(err.message);
+          return err.code == 400 && "invalid token" == err.message;
         }));
       });
 
       it("should send an error if the data is not valid", function() {
         // Sets up the hawkRequest stub to trigger the callback with
         // an error
         hawkRequestStub.callsArgWith(4, null, "{}");
 
@@ -296,17 +296,17 @@ describe("loop.Client", function() {
 
       it("should send an error when the request fails", function() {
         hawkRequestStub.callsArgWith(4, fakeErrorRes);
 
         client.setupOutgoingCall(calleeIds, callType, callback);
 
         sinon.assert.calledOnce(callback);
         sinon.assert.calledWithExactly(callback, sinon.match(function(err) {
-          return /400.*invalid token/.test(err.message);
+          return err.code == 400 && "invalid token" == err.message;
         }));
       });
 
       it("should send an error if the data is not valid", function() {
         // Sets up the hawkRequest stub to trigger the callback with
         // an error
         hawkRequestStub.callsArgWith(4, null, "{}");
 
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -341,18 +341,18 @@ describe("loop.panel", function() {
         });
 
       it("should update CallUrlResult with the call url", function() {
         var urlField = view.getDOMNode().querySelector("input[type='url']");
 
         expect(urlField.value).eql(callUrlData.callUrl);
       });
 
-      it("should reset all pending notifications", function() {
-        sinon.assert.calledOnce(view.props.notifications.reset);
+      it("should have 0 pending notifications", function() {
+        expect(view.props.notifications.length).eql(0);
       });
 
       it("should display a share button for email", function() {
         fakeClient.requestCallUrl = sandbox.stub();
         var view = TestUtils.renderIntoDocument(loop.panel.CallUrlResult({
           notifications: notifications,
           client: fakeClient
         }));
--- a/browser/components/loop/test/mochitest/browser_fxa_login.js
+++ b/browser/components/loop/test/mochitest/browser_fxa_login.js
@@ -9,25 +9,53 @@
 
 const {
   gFxAOAuthTokenData,
   gFxAOAuthProfile,
 } = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
 
 const BASE_URL = "http://mochi.test:8888/browser/browser/components/loop/test/mochitest/loop_fxa.sjs?";
 
+function* checkFxA401() {
+  let err = MozLoopService.errors.get("login");
+  ise(err.code, 401, "Check error code");
+  ise(err.friendlyMessage, getLoopString("could_not_authenticate"),
+      "Check friendlyMessage");
+  ise(err.friendlyDetails, getLoopString("password_changed_question"),
+      "Check friendlyDetails");
+  ise(err.friendlyDetailsButtonLabel, getLoopString("retry_button"),
+      "Check friendlyDetailsButtonLabel");
+  let loopButton = document.getElementById("loop-call-button");
+  is(loopButton.getAttribute("state"), "error",
+     "state of loop button should be error after a 401 with login");
+
+  let loopPanel = document.getElementById("loop-notification-panel");
+  yield loadLoopPanel({loopURL: BASE_URL });
+  let loopDoc = document.getElementById("loop").contentDocument;
+  is(loopDoc.querySelector(".alert-error .message").textContent,
+     getLoopString("could_not_authenticate"),
+     "Check error bar message");
+  is(loopDoc.querySelector(".details-error .details").textContent,
+     getLoopString("password_changed_question"),
+     "Check error bar details message");
+  is(loopDoc.querySelector(".details-error .detailsButton").textContent,
+     getLoopString("retry_button"),
+     "Check error bar details button");
+  loopPanel.hidePopup();
+}
+
 add_task(function* setup() {
   Services.prefs.setCharPref("loop.server", BASE_URL);
   Services.prefs.setCharPref("services.push.serverURL", "ws://localhost/");
   registerCleanupFunction(function* () {
     info("cleanup time");
     yield promiseDeletedOAuthParams(BASE_URL);
     Services.prefs.clearUserPref("loop.server");
     Services.prefs.clearUserPref("services.push.serverURL");
-    resetFxA();
+    yield resetFxA();
     Services.prefs.clearUserPref(MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.GUEST));
   });
 });
 
 add_task(function* checkOAuthParams() {
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
@@ -47,39 +75,39 @@ add_task(function* checkOAuthParams() {
 
 add_task(function* basicAuthorization() {
   let result = yield MozLoopServiceInternal.promiseFxAOAuthAuthorization();
   is(result.code, "code1", "Check code");
   is(result.state, "state", "Check state");
 });
 
 add_task(function* sameOAuthClientForTwoCalls() {
-  resetFxA();
+  yield resetFxA();
   let client1 = yield MozLoopServiceInternal.promiseFxAOAuthClient();
   let client2 = yield MozLoopServiceInternal.promiseFxAOAuthClient();
   ise(client1, client2, "The same client should be returned");
 });
 
 add_task(function* paramsInvalid() {
-  resetFxA();
+  yield resetFxA();
   // Delete the params so an empty object is returned.
   yield promiseDeletedOAuthParams(BASE_URL);
   let result = null;
   let loginPromise = MozLoopService.logInToFxA();
   let caught = false;
   yield loginPromise.catch(() => {
     ok(true, "The login promise should be rejected due to invalid params");
     caught = true;
   });
   ok(caught, "Should have caught the rejection");
   is(result, null, "No token data should be returned");
 });
 
 add_task(function* params_no_hawk_session() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
     test_error: "params_no_hawk",
   };
@@ -96,78 +124,79 @@ add_task(function* params_no_hawk_sessio
   ise(Services.prefs.getPrefType(prefName),
       Services.prefs.PREF_INVALID,
       "Check FxA hawk token is not set");
 });
 
 add_task(function* params_nonJSON() {
   Services.prefs.setCharPref("loop.server", "https://loop.invalid");
   // Reset after changing the server so a new HawkClient is created
-  resetFxA();
+  yield resetFxA();
 
   let loginPromise = MozLoopService.logInToFxA();
   let caught = false;
   yield loginPromise.catch(() => {
     ok(true, "The login promise should be rejected due to non-JSON params");
     caught = true;
   });
   ok(caught, "Should have caught the rejection");
   Services.prefs.setCharPref("loop.server", BASE_URL);
 });
 
 add_task(function* invalidState() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "invalid_state",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
   let loginPromise = MozLoopService.logInToFxA();
   yield loginPromise.catch((error) => {
     ok(error, "The login promise should be rejected due to invalid state");
   });
 });
 
 add_task(function* basicRegistrationWithoutSession() {
-  resetFxA();
+  yield resetFxA();
   yield promiseDeletedOAuthParams(BASE_URL);
 
   let caught = false;
   yield MozLoopServiceInternal.promiseFxAOAuthToken("code1", "state").catch((error) => {
     caught = true;
     is(error.code, 401, "Should have returned a 401");
   });
   ok(caught, "Should have caught the error requesting /token without a hawk session");
+  yield checkFxA401();
 });
 
 add_task(function* basicRegistration() {
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
-  resetFxA();
+  yield resetFxA();
   // Create a fake FxA hawk session token
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
 
   let tokenData = yield MozLoopServiceInternal.promiseFxAOAuthToken("code1", "state");
   is(tokenData.access_token, "code1_access_token", "Check access_token");
   is(tokenData.scope, "profile", "Check scope");
   is(tokenData.token_type, "bearer", "Check token_type");
 });
 
 add_task(function* registrationWithInvalidState() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "invalid_state",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
@@ -181,17 +210,17 @@ add_task(function* registrationWithInval
     ok(false, "Promise should have rejected");
   },
   error => {
     is(error.code, 400, "Check error code");
   });
 });
 
 add_task(function* registrationWith401() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
     test_error: "token_401",
   };
@@ -199,20 +228,22 @@ add_task(function* registrationWith401()
 
   let tokenPromise = MozLoopServiceInternal.promiseFxAOAuthToken("code1", "state");
   yield tokenPromise.then(body => {
     ok(false, "Promise should have rejected");
   },
   error => {
     is(error.code, 401, "Check error code");
   });
+
+  yield checkFxA401();
 });
 
 add_task(function* basicAuthorizationAndRegistration() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
@@ -267,17 +298,17 @@ add_task(function* basicAuthorizationAnd
   registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
   ise(registrationResponse.response, null,
       "Check registration was deleted on the server");
   is(visibleEmail.textContent, "Guest", "Guest should be displayed on the panel again after logout");
   is(MozLoopService.userProfile, null, "userProfile should be null after logout");
 });
 
 add_task(function* loginWithParams401() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
     test_error: "params_401",
   };
@@ -287,20 +318,22 @@ add_task(function* loginWithParams401() 
   let loginPromise = MozLoopService.logInToFxA();
   yield loginPromise.then(tokenData => {
     ok(false, "Promise should have rejected");
   },
   error => {
     ise(error.code, 401, "Check error code");
     ise(gFxAOAuthTokenData, null, "Check there is no saved token data");
   });
+
+  yield checkFxA401();
 });
 
 add_task(function* logoutWithIncorrectPushURL() {
-  resetFxA();
+  yield resetFxA();
   let pushURL = "http://www.example.com/";
   mockPushHandler.pushUrl = pushURL;
 
   // Create a fake FxA hawk session token
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
 
   yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, pushURL);
@@ -313,40 +346,36 @@ add_task(function* logoutWithIncorrectPu
   });
   ok(caught, "Should have caught an error logging out with a mismatched push URL");
   checkLoggedOutState();
   registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
   ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL wasn't deleted");
 });
 
 add_task(function* logoutWithNoPushURL() {
-  resetFxA();
+  yield resetFxA();
   let pushURL = "http://www.example.com/";
   mockPushHandler.pushUrl = pushURL;
 
   // Create a fake FxA hawk session token
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
 
   yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, pushURL);
   let registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
   ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL");
   mockPushHandler.pushUrl = null;
-  let caught = false;
-  yield MozLoopService.logOutFromFxA().catch((error) => {
-    caught = true;
-  });
-  ok(caught, "Should have caught an error logging out without a push URL");
+  yield MozLoopService.logOutFromFxA();
   checkLoggedOutState();
   registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
   ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL wasn't deleted");
 });
 
 add_task(function* loginWithRegistration401() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
     test_error: "token_401",
   };
@@ -355,9 +384,11 @@ add_task(function* loginWithRegistration
   let loginPromise = MozLoopService.logInToFxA();
   yield loginPromise.then(tokenData => {
     ok(false, "Promise should have rejected");
   },
   error => {
     ise(error.code, 401, "Check error code");
     ise(gFxAOAuthTokenData, null, "Check there is no saved token data");
   });
+
+  yield checkFxA401();
 });
--- a/browser/components/loop/test/mochitest/head.js
+++ b/browser/components/loop/test/mochitest/head.js
@@ -14,20 +14,31 @@ const WAS_OFFLINE = Services.io.offline;
 
 var gMozLoopAPI;
 
 function promiseGetMozLoopAPI() {
   let deferred = Promise.defer();
   let loopPanel = document.getElementById("loop-notification-panel");
   let btn = document.getElementById("loop-call-button");
 
-  // Wait for the popup to be shown, then we can get the iframe and
+  // Wait for the popup to be shown if it's not already, then we can get the iframe and
   // wait for the iframe's load to be completed.
-  loopPanel.addEventListener("popupshown", function onpopupshown() {
-    loopPanel.removeEventListener("popupshown", onpopupshown, true);
+  if (loopPanel.state == "closing" || loopPanel.state == "closed") {
+    loopPanel.addEventListener("popupshown", () => {
+      loopPanel.removeEventListener("popupshown", onpopupshown, true);
+      onpopupshown();
+    }, true);
+
+    // Now we're setup, click the button.
+    btn.click();
+  } else {
+    setTimeout(onpopupshown, 0);
+  }
+
+  function onpopupshown() {
     let iframe = document.getElementById(btn.getAttribute("notificationFrameId"));
 
     if (iframe.contentDocument &&
         iframe.contentDocument.readyState == "complete") {
       gMozLoopAPI = iframe.contentWindow.navigator.wrappedJSObject.mozLoop;
 
       deferred.resolve();
     } else {
@@ -36,20 +47,17 @@ function promiseGetMozLoopAPI() {
 
         gMozLoopAPI = iframe.contentWindow.navigator.wrappedJSObject.mozLoop;
 
         // We do this in an execute soon to allow any other event listeners to
         // be handled, just in case.
         deferred.resolve();
       }, true);
     }
-  }, true);
-
-  // Now we're setup, click the button.
-  btn.click();
+  }
 
   // Remove the iframe after each test. This also avoids mochitest complaining
   // about leaks on shutdown as we intentionally hold the iframe open for the
   // life of the application.
   registerCleanupFunction(function() {
     loopPanel.hidePopup();
     let frameId = btn.getAttribute("notificationFrameId");
     let frame = document.getElementById(frameId);
@@ -102,25 +110,29 @@ function promiseOAuthParamsSetup(baseURL
   xhr.setRequestHeader("X-Params", JSON.stringify(params));
   xhr.addEventListener("load", () => deferred.resolve(xhr));
   xhr.addEventListener("error", error => deferred.reject(error));
   xhr.send();
 
   return deferred.promise;
 }
 
-function resetFxA() {
+function* resetFxA() {
   let global = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
   global.gHawkClient = null;
   global.gFxAOAuthClientPromise = null;
   global.gFxAOAuthClient = null;
   global.gFxAOAuthTokenData = null;
   global.gFxAOAuthProfile = null;
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.clearUserPref(fxASessionPref);
+  MozLoopService.errors.clear();
+  let notified = promiseObserverNotified("loop-status-changed");
+  MozLoopServiceInternal.notifyStatusChanged();
+  yield notified;
 }
 
 function setInternalLoopGlobal(aName, aValue) {
   let global = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
   global[aName] = aValue;
 }
 
 function checkLoggedOutState() {
@@ -167,16 +179,20 @@ function promiseOAuthGetRegistration(bas
   xhr.responseType = "json";
   xhr.addEventListener("load", () => deferred.resolve(xhr));
   xhr.addEventListener("error", deferred.reject);
   xhr.send();
 
   return deferred.promise;
 }
 
+function getLoopString(stringID) {
+  return MozLoopServiceInternal.localizedStrings[stringID].textContent;
+}
+
 /**
  * This is used to fake push registration and notifications for
  * MozLoopService tests. There is only one object created per test instance, as
  * once registration has taken place, the object cannot currently be changed.
  */
 let mockPushHandler = {
   // This sets the registration result to be returned when initialize
   // is called. By default, it is equivalent to success.
--- a/browser/components/loop/test/xpcshell/head.js
+++ b/browser/components/loop/test/xpcshell/head.js
@@ -2,19 +2,18 @@
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Http.jsm");
 Cu.import("resource://testing-common/httpd.js");
-
-XPCOMUtils.defineLazyModuleGetter(this, "MozLoopService",
-                                  "resource:///modules/loop/MozLoopService.jsm");
+Cu.import("resource:///modules/loop/MozLoopService.jsm");
+const { MozLoopServiceInternal } = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
 
 XPCOMUtils.defineLazyModuleGetter(this, "MozLoopPushHandler",
                                   "resource:///modules/loop/MozLoopPushHandler.jsm");
 
 const kMockWebSocketChannelName = "Mock WebSocket Channel";
 const kWebSocketChannelContractID = "@mozilla.org/network/protocol;1?name=wss";
 
 const kServerPushUrl = "http://localhost:3456";
@@ -57,16 +56,20 @@ function waitForCondition(aConditionFn, 
     do_timeout(aCheckInterval, tryNow);
   }
   let deferred = Promise.defer();
   let tries = 0;
   tryAgain();
   return deferred.promise;
 }
 
+function getLoopString(stringID) {
+  return MozLoopServiceInternal.localizedStrings[stringID].textContent;
+}
+
 /**
  * This is used to fake push registration and notifications for
  * MozLoopService tests. There is only one object created per test instance, as
  * once registration has taken place, the object cannot currently be changed.
  */
 let mockPushHandler = {
   // This sets the registration result to be returned when initialize
   // is called. By default, it is equivalent to success.
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/xpcshell/test_loopservice_hawk_errors.js
@@ -0,0 +1,194 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Unit tests for the error handling for hawkRequest via setError.
+ *
+ * hawkRequest calls setError itself for 401. Consumers need to report other
+ * errors to setError themseleves.
+ */
+
+"use strict";
+
+const { INVALID_AUTH_TOKEN } = Cu.import("resource:///modules/loop/MozLoopService.jsm");
+
+/**
+ * An HTTP request for /NNN responds with a request with a status of NNN.
+ */
+function errorRequestHandler(request, response) {
+  let responseCode = request.path.substring(1);
+  response.setStatusLine(null, responseCode, "Error");
+  if (responseCode == 401) {
+    response.write(JSON.stringify({
+      code: parseInt(responseCode),
+      errno: INVALID_AUTH_TOKEN,
+      error: "INVALID_AUTH_TOKEN",
+      message: "INVALID_AUTH_TOKEN",
+    }));
+  }
+}
+
+add_task(function* setup_server() {
+  loopServer.registerPathHandler("/401", errorRequestHandler);
+  loopServer.registerPathHandler("/404", errorRequestHandler);
+  loopServer.registerPathHandler("/500", errorRequestHandler);
+  loopServer.registerPathHandler("/503", errorRequestHandler);
+});
+
+add_task(function* error_offline() {
+  Services.io.offline = true;
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/offline", "GET").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      MozLoopServiceInternal.setError("testing", error);
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      // Network errors are converted to the "network" errorType.
+      let err = MozLoopService.errors.get("network");
+      Assert.strictEqual(err.code, null);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("could_not_connect"));
+      Assert.strictEqual(err.friendlyDetails, getLoopString("check_internet_connection"));
+      Assert.strictEqual(err.friendlyDetailsButtonLabel, getLoopString("retry_button"));
+  });
+  Services.io.offline = false;
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* guest_401() {
+  Services.prefs.setCharPref("loop.hawk-session-token", "guest");
+  Services.prefs.setCharPref("loop.hawk-session-token.fxa", "fxa");
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/401", "POST").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      Assert.strictEqual(Services.prefs.getPrefType("loop.hawk-session-token"),
+                         Services.prefs.PREF_INVALID,
+                         "Guest session token should have been cleared");
+      Assert.strictEqual(Services.prefs.getCharPref("loop.hawk-session-token.fxa"),
+                         "fxa",
+                         "FxA session token should NOT have been cleared");
+
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      let err = MozLoopService.errors.get("registration");
+      Assert.strictEqual(err.code, 401);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("session_expired_error_description"));
+      Assert.equal(err.friendlyDetails, null);
+      Assert.equal(err.friendlyDetailsButtonLabel, null);
+  });
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* fxa_401() {
+  Services.prefs.setCharPref("loop.hawk-session-token", "guest");
+  Services.prefs.setCharPref("loop.hawk-session-token.fxa", "fxa");
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.FXA, "/401", "POST").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      Assert.strictEqual(Services.prefs.getCharPref("loop.hawk-session-token"),
+                         "guest",
+                         "Guest session token should NOT have been cleared");
+      Assert.strictEqual(Services.prefs.getPrefType("loop.hawk-session-token.fxa"),
+                         Services.prefs.PREF_INVALID,
+                         "Fxa session token should have been cleared");
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      let err = MozLoopService.errors.get("login");
+      Assert.strictEqual(err.code, 401);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("could_not_authenticate"));
+      Assert.strictEqual(err.friendlyDetails, getLoopString("password_changed_question"));
+      Assert.strictEqual(err.friendlyDetailsButtonLabel, getLoopString("retry_button"));
+  });
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* error_404() {
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/404", "GET").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      MozLoopServiceInternal.setError("testing", error);
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      let err = MozLoopService.errors.get("testing");
+      Assert.strictEqual(err.code, 404);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("generic_failure_title"));
+      Assert.equal(err.friendlyDetails, null);
+      Assert.equal(err.friendlyDetailsButtonLabel, null);
+  });
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* error_500() {
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/500", "GET").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      MozLoopServiceInternal.setError("testing", error);
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      let err = MozLoopService.errors.get("testing");
+      Assert.strictEqual(err.code, 500);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("service_not_available"));
+      Assert.strictEqual(err.friendlyDetails, getLoopString("try_again_later"));
+      Assert.strictEqual(err.friendlyDetailsButtonLabel, getLoopString("retry_button"));
+  });
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* profile_500() {
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/500", "GET").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      MozLoopServiceInternal.setError("profile", error);
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      let err = MozLoopService.errors.get("profile");
+      Assert.strictEqual(err.code, 500);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("problem_accessing_account"));
+      Assert.equal(err.friendlyDetails, null);
+      Assert.equal(err.friendlyDetailsButtonLabel, null);
+  });
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* error_503() {
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/503", "GET").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      MozLoopServiceInternal.setError("testing", error);
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      let err = MozLoopService.errors.get("testing");
+      Assert.strictEqual(err.code, 503);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("service_not_available"));
+      Assert.strictEqual(err.friendlyDetails, getLoopString("try_again_later"));
+      Assert.strictEqual(err.friendlyDetailsButtonLabel, getLoopString("retry_button"));
+  });
+});
+
+add_task(cleanup_between_tests);
+
+function run_test() {
+  setupFakeLoopServer();
+
+  // Set the expiry time one hour in the future so that an error is shown when the guest session expires.
+  MozLoopServiceInternal.expiryTimeSeconds = (Date.now() / 1000) + 3600;
+
+  do_register_cleanup(() => {
+    Services.prefs.clearUserPref("loop.hawk-session-token");
+    Services.prefs.clearUserPref("loop.hawk-session-token.fxa");
+    Services.prefs.clearUserPref("loop.urlsExpiryTimeSeconds");
+    MozLoopService.errors.clear();
+  });
+
+  run_next_test();
+}
+
+function* cleanup_between_tests() {
+  MozLoopService.errors.clear();
+  Services.io.offline = false;
+}
--- a/browser/components/loop/test/xpcshell/xpcshell.ini
+++ b/browser/components/loop/test/xpcshell/xpcshell.ini
@@ -2,16 +2,17 @@
 head = head.js
 tail =
 firefox-appdir = browser
 
 [test_loopapi_hawk_request.js]
 [test_looppush_initialize.js]
 [test_loopservice_dnd.js]
 [test_loopservice_expiry.js]
+[test_loopservice_hawk_errors.js]
 [test_loopservice_loop_prefs.js]
 [test_loopservice_initialize.js]
 [test_loopservice_locales.js]
 [test_loopservice_notification.js]
 [test_loopservice_registration.js]
 [test_loopservice_token_invalid.js]
 [test_loopservice_token_save.js]
 [test_loopservice_token_send.js]
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -73,17 +73,22 @@
   var mockWebSocket = new loop.CallConnectionWebSocket({
     url: "fake",
     callId: "fakeId",
     websocketToken: "fakeToken"
   });
 
   var notifications = new loop.shared.models.NotificationCollection();
   var errNotifications = new loop.shared.models.NotificationCollection();
-  errNotifications.error("Error!");
+  errNotifications.add({
+    level: "error",
+    message: "Could Not Authenticate",
+    details: "Did you change your password?",
+    detailsButtonLabel: "Retry",
+  });
 
   var Example = React.createClass({displayName: 'Example',
     render: function() {
       var cx = React.addons.classSet;
       return (
         React.DOM.div({className: "example"}, 
           React.DOM.h3(null, this.props.summary), 
           React.DOM.div({className: cx({comp: true, dashed: this.props.dashed}), 
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -73,17 +73,22 @@
   var mockWebSocket = new loop.CallConnectionWebSocket({
     url: "fake",
     callId: "fakeId",
     websocketToken: "fakeToken"
   });
 
   var notifications = new loop.shared.models.NotificationCollection();
   var errNotifications = new loop.shared.models.NotificationCollection();
-  errNotifications.error("Error!");
+  errNotifications.add({
+    level: "error",
+    message: "Could Not Authenticate",
+    details: "Did you change your password?",
+    detailsButtonLabel: "Retry",
+  });
 
   var Example = React.createClass({
     render: function() {
       var cx = React.addons.classSet;
       return (
         <div className="example">
           <h3>{this.props.summary}</h3>
           <div className={cx({comp: true, dashed: this.props.dashed})}