Bug 1142596 - Use cached FxA OAuth tokens in Reading List sync. r=rnewman, a=readinglist
authorNick Alexander <nalexander@mozilla.com>
Tue, 24 Mar 2015 23:00:34 -0700
changeset 258209 0aedf96a7cdc
parent 258208 ac9b83aca21f
child 258210 32b6b2c4a69e
push id4620
push userrnewman@mozilla.com
push date2015-04-02 16:21 +0000
treeherdermozilla-beta@27f61020a9e4 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman, readinglist
bugs1142596, 1147245
milestone38.0
Bug 1142596 - Use cached FxA OAuth tokens in Reading List sync. r=rnewman, a=readinglist ======== https://github.com/mozilla-services/android-sync/commit/9b406122ef93dea4be4bff8d84caa3a2fae54d39 Author: Nick Alexander <nalexander@mozilla.com> Bug 1142596 - Post: Use production OAuth and Reading List endpoints. ======== https://github.com/mozilla-services/android-sync/commit/9e5368b4aaf6fca78230d184e901bb78020a771f Author: Nick Alexander <nalexander@mozilla.com> Bug 1142596 - Part 4: Make ReadingListSyncAdapter use oauth tokens produced and cached by the authenticator. ======== https://github.com/mozilla-services/android-sync/commit/fbef93698dac1e060fd6ca71dee54c74f1d14fd8 Author: Nick Alexander <nalexander@mozilla.com> Date: Tue Mar 24 22:49:52 2015 -0700 Bug 1142596 - Part 3: Implement getAuthToken with token types of the form oauth::scope. Be aware that there are two levels of token invalidation relevant here. The first level is when a consumer uses an oauth token and gets a 401; in this case, the consumer *must* call Android's own invalidateAuthToken. The second level is when the oauth client itself gets a 401 trying to fetch an oauth token; in this case, the internal state of the Firefox Account needs to be pushed back. ======== https://github.com/mozilla-services/android-sync/commit/e4e2247b4e6a080b8e76008595a7e75b734a7c64 Author: Nick Alexander <nalexander@mozilla.com> Date: Tue Mar 24 22:43:26 2015 -0700 Bug 1142596 - Part 2: Extract login state machine delegate encapsulating expirations. ======== https://github.com/mozilla-services/android-sync/commit/f1f716cc8831b947078f066cf5ffefbece7f695e Author: Nick Alexander <nalexander@mozilla.com> Date: Tue Mar 24 22:14:47 2015 -0700 Bug 1142596 - Part 1: Surface Reading List authentication errors. ======== https://github.com/mozilla-services/android-sync/commit/5833cbbf711cdc4d2b4d861988a454c8b33241c3 Author: Nick Alexander <nalexander@mozilla.com> Date: Tue Mar 24 22:01:46 2015 -0700 Bug 1142596 - Pre: Add note about deleting cached oauth tokens. Deleting cached oauth tokens is tracked by Bug 1147245. ======== https://github.com/mozilla-services/android-sync/commit/b0165a6c14bf04de9bb2854ba4d0cdf772e58b5b Author: Nick Alexander <nalexander@mozilla.com> Date: Tue Mar 24 23:06:49 2015 -0700 Bug 1142596 - Pre: Trim imports.
mobile/android/base/android-services.mozbuild
mobile/android/base/background/fxa/FxAccountUtils.java
mobile/android/base/background/fxa/oauth/FxAccountAbstractClientException.java
mobile/android/base/fxa/authenticator/FxADefaultLoginStateMachineDelegate.java
mobile/android/base/fxa/authenticator/FxAccountAuthenticator.java
mobile/android/base/fxa/receivers/FxAccountDeletedService.java
mobile/android/base/fxa/sync/FxAccountNotificationManager.java
mobile/android/base/fxa/sync/FxAccountSyncAdapter.java
mobile/android/base/reading/ReadingListInvalidAuthenticationException.java
mobile/android/base/reading/ReadingListSyncAdapter.java
mobile/android/base/reading/ReadingListSynchronizer.java
mobile/android/base/sync/net/MozResponse.java
--- a/mobile/android/base/android-services.mozbuild
+++ b/mobile/android/base/android-services.mozbuild
@@ -863,16 +863,17 @@ sync_java_files = [
     'fxa/activities/FxAccountUpdateCredentialsActivity.java',
     'fxa/activities/FxAccountVerifiedAccountActivity.java',
     'fxa/authenticator/AccountPickler.java',
     'fxa/authenticator/AndroidFxAccount.java',
     'fxa/authenticator/FxAccountAuthenticator.java',
     'fxa/authenticator/FxAccountAuthenticatorService.java',
     'fxa/authenticator/FxAccountLoginDelegate.java',
     'fxa/authenticator/FxAccountLoginException.java',
+    'fxa/authenticator/FxADefaultLoginStateMachineDelegate.java',
     'fxa/FirefoxAccounts.java',
     'fxa/FxAccountConstants.java',
     'fxa/login/BaseRequestDelegate.java',
     'fxa/login/Cohabiting.java',
     'fxa/login/Doghouse.java',
     'fxa/login/Engaged.java',
     'fxa/login/FxAccountLoginStateMachine.java',
     'fxa/login/FxAccountLoginTransition.java',
@@ -1166,16 +1167,17 @@ reading_list_service_java_files = [
     'reading/FetchSpec.java',
     'reading/LocalReadingListStorage.java',
     'reading/ReadingListChangeAccumulator.java',
     'reading/ReadingListClient.java',
     'reading/ReadingListClientContentValuesFactory.java',
     'reading/ReadingListClientRecordFactory.java',
     'reading/ReadingListConstants.java',
     'reading/ReadingListDeleteDelegate.java',
+    'reading/ReadingListInvalidAuthenticationException.java',
     'reading/ReadingListRecord.java',
     'reading/ReadingListRecordDelegate.java',
     'reading/ReadingListRecordResponse.java',
     'reading/ReadingListRecordUploadDelegate.java',
     'reading/ReadingListResponse.java',
     'reading/ReadingListStorage.java',
     'reading/ReadingListStorageResponse.java',
     'reading/ReadingListSyncAdapter.java',
--- a/mobile/android/base/background/fxa/FxAccountUtils.java
+++ b/mobile/android/base/background/fxa/FxAccountUtils.java
@@ -9,26 +9,24 @@ import java.math.BigInteger;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.security.GeneralSecurityException;
 import java.security.InvalidKeyException;
 import java.security.NoSuchAlgorithmException;
 
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.R;
-import org.mozilla.gecko.R.string;
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.nativecode.NativeCrypto;
 import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.crypto.HKDF;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 import org.mozilla.gecko.sync.crypto.PBKDF2;
 
 import android.content.Context;
-import android.os.Build;
 
 public class FxAccountUtils {
   private static final String LOG_TAG = FxAccountUtils.class.getSimpleName();
 
   public static final int SALT_LENGTH_BYTES = 32;
   public static final int SALT_LENGTH_HEX = 2 * SALT_LENGTH_BYTES;
 
   public static final int HASH_LENGTH_BYTES = 16;
--- a/mobile/android/base/background/fxa/oauth/FxAccountAbstractClientException.java
+++ b/mobile/android/base/background/fxa/oauth/FxAccountAbstractClientException.java
@@ -4,16 +4,17 @@
 
 package org.mozilla.gecko.background.fxa.oauth;
 
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.HTTPFailureException;
 import org.mozilla.gecko.sync.net.SyncStorageResponse;
 
 import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.HttpStatus;
 
 /**
  * From <a href="https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md">https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md</a>.
  */
 public class FxAccountAbstractClientException extends Exception {
   private static final long serialVersionUID = 1953459541558266597L;
 
   public FxAccountAbstractClientException(String detailMessage) {
@@ -46,16 +47,20 @@ public class FxAccountAbstractClientExce
       this.message = message;
       this.body = body;
     }
 
     @Override
     public String toString() {
       return "<FxAccountAbstractClientRemoteException " + this.httpStatusCode + " [" + this.apiErrorNumber + "]: " + this.message + ">";
     }
+
+    public boolean isInvalidAuthentication() {
+      return this.httpStatusCode == HttpStatus.SC_UNAUTHORIZED;
+    }
   }
 
   public static class FxAccountAbstractClientMalformedResponseException extends FxAccountAbstractClientRemoteException {
     private static final long serialVersionUID = 1209313149952001098L;
 
     public FxAccountAbstractClientMalformedResponseException(HttpResponse response) {
       super(response, 0, FxAccountOAuthRemoteError.UNKNOWN_ERROR, "Response malformed", "Response malformed", new ExtendedJSONObject());
     }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/fxa/authenticator/FxADefaultLoginStateMachineDelegate.java
@@ -0,0 +1,84 @@
+/* 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/. */
+
+package org.mozilla.gecko.fxa.authenticator;
+
+import java.security.NoSuchAlgorithmException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountClient;
+import org.mozilla.gecko.background.fxa.FxAccountClient20;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.LoginStateMachineDelegate;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition;
+import org.mozilla.gecko.fxa.login.Married;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+import org.mozilla.gecko.fxa.login.StateFactory;
+import org.mozilla.gecko.fxa.sync.FxAccountNotificationManager;
+import org.mozilla.gecko.fxa.sync.FxAccountSyncAdapter;
+
+import android.content.Context;
+
+public abstract class FxADefaultLoginStateMachineDelegate implements LoginStateMachineDelegate {
+  protected final static String LOG_TAG = LoginStateMachineDelegate.class.getSimpleName();
+
+  protected final Context context;
+  protected final AndroidFxAccount fxAccount;
+  protected final Executor executor;
+  protected final FxAccountClient client;
+
+  public FxADefaultLoginStateMachineDelegate(Context context, AndroidFxAccount fxAccount) {
+    this.context = context;
+    this.fxAccount = fxAccount;
+    this.executor = Executors.newSingleThreadExecutor();
+    this.client = new FxAccountClient20(fxAccount.getAccountServerURI(), executor);
+  }
+
+  abstract public void handleNotMarried(State notMarried);
+  abstract public void handleMarried(Married married);
+
+  @Override
+  public FxAccountClient getClient() {
+    return client;
+  }
+
+  @Override
+  public long getCertificateDurationInMilliseconds() {
+    return 12 * 60 * 60 * 1000;
+  }
+
+  @Override
+  public long getAssertionDurationInMilliseconds() {
+    return 15 * 60 * 1000;
+  }
+
+  @Override
+  public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException {
+    return StateFactory.generateKeyPair();
+  }
+
+  @Override
+  public void handleTransition(Transition transition, State state) {
+    Logger.info(LOG_TAG, "handleTransition: " + transition + " to " + state.getStateLabel());
+  }
+
+  @Override
+  public void handleFinal(State state) {
+    Logger.info(LOG_TAG, "handleFinal: in " + state.getStateLabel());
+    fxAccount.setState(state);
+    // Update any notifications displayed.
+    final FxAccountNotificationManager notificationManager = new FxAccountNotificationManager(FxAccountSyncAdapter.NOTIFICATION_ID);
+    notificationManager.update(context, fxAccount);
+
+    if (state.getStateLabel() != StateLabel.Married) {
+      handleNotMarried(state);
+      return;
+    } else {
+      handleMarried((Married) state);
+    }
+  }
+}
--- a/mobile/android/base/fxa/authenticator/FxAccountAuthenticator.java
+++ b/mobile/android/base/fxa/authenticator/FxAccountAuthenticator.java
@@ -1,29 +1,52 @@
 /* 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/. */
 
 package org.mozilla.gecko.fxa.authenticator;
 
+import java.security.NoSuchAlgorithmException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
 import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountClient;
+import org.mozilla.gecko.background.fxa.FxAccountClient20;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient.RequestDelegate;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException.FxAccountAbstractClientRemoteException;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10.AuthorizationResponse;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.browserid.JSONWebTokenUtils;
 import org.mozilla.gecko.fxa.FxAccountConstants;
 import org.mozilla.gecko.fxa.activities.FxAccountGetStartedActivity;
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine;
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.LoginStateMachineDelegate;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition;
+import org.mozilla.gecko.fxa.login.Married;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+import org.mozilla.gecko.fxa.login.StateFactory;
+import org.mozilla.gecko.fxa.sync.FxAccountNotificationManager;
+import org.mozilla.gecko.fxa.sync.FxAccountSyncAdapter;
 
 import android.accounts.AbstractAccountAuthenticator;
 import android.accounts.Account;
 import android.accounts.AccountAuthenticatorResponse;
 import android.accounts.AccountManager;
 import android.accounts.NetworkErrorException;
 import android.content.Context;
 import android.content.Intent;
 import android.os.Bundle;
 
 public class FxAccountAuthenticator extends AbstractAccountAuthenticator {
   public static final String LOG_TAG = FxAccountAuthenticator.class.getSimpleName();
+  public static final int UNKNOWN_ERROR_CODE = 999;
 
   protected final Context context;
   protected final AccountManager accountManager;
 
   public FxAccountAuthenticator(Context context) {
     super(context);
     this.context = context;
     this.accountManager = AccountManager.get(context);
@@ -63,22 +86,200 @@ public class FxAccountAuthenticator exte
 
   @Override
   public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
     Logger.debug(LOG_TAG, "editProperties");
 
     return null;
   }
 
+  protected static class Responder {
+    final AccountAuthenticatorResponse response;
+    final Account account;
+
+    public Responder(AccountAuthenticatorResponse response, Account account) {
+      this.response = response;
+      this.account = account;
+    }
+
+    public void fail(Exception e) {
+      Logger.warn(LOG_TAG, "Responding with error!", e);
+      final Bundle result = new Bundle();
+      result.putInt(AccountManager.KEY_ERROR_CODE, UNKNOWN_ERROR_CODE);
+      result.putString(AccountManager.KEY_ERROR_MESSAGE, e.toString());
+      response.onResult(result);
+    }
+
+    public void succeed(String authToken) {
+      Logger.info(LOG_TAG, "Responding with success!");
+      final Bundle result = new Bundle();
+      result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
+      result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type);
+      result.putString(AccountManager.KEY_AUTHTOKEN, authToken);
+      response.onResult(result);
+    }
+  }
+
+  public abstract static class FxADefaultLoginStateMachineDelegate implements LoginStateMachineDelegate {
+    protected final Context context;
+    protected final AndroidFxAccount fxAccount;
+    protected final Executor executor;
+    protected final FxAccountClient client;
+
+    public FxADefaultLoginStateMachineDelegate(Context context, AndroidFxAccount fxAccount) {
+      this.context = context;
+      this.fxAccount = fxAccount;
+      this.executor = Executors.newSingleThreadExecutor();
+      this.client = new FxAccountClient20(fxAccount.getAccountServerURI(), executor);
+    }
+
+    @Override
+    public FxAccountClient getClient() {
+      return client;
+    }
+
+    @Override
+    public long getCertificateDurationInMilliseconds() {
+      return 12 * 60 * 60 * 1000;
+    }
+
+    @Override
+    public long getAssertionDurationInMilliseconds() {
+      return 15 * 60 * 1000;
+    }
+
+    @Override
+    public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException {
+      return StateFactory.generateKeyPair();
+    }
+
+    @Override
+    public void handleTransition(Transition transition, State state) {
+      Logger.info(LOG_TAG, "handleTransition: " + transition + " to " + state.getStateLabel());
+    }
+
+    abstract public void handleNotMarried(State notMarried);
+    abstract public void handleMarried(Married married);
+
+    @Override
+    public void handleFinal(State state) {
+      Logger.info(LOG_TAG, "handleFinal: in " + state.getStateLabel());
+      fxAccount.setState(state);
+      // Update any notifications displayed.
+      final FxAccountNotificationManager notificationManager = new FxAccountNotificationManager(FxAccountSyncAdapter.NOTIFICATION_ID);
+      notificationManager.update(context, fxAccount);
+
+      if (state.getStateLabel() != StateLabel.Married) {
+        handleNotMarried(state);
+        return;
+      } else {
+        handleMarried((Married) state);
+      }
+    }
+  }
+
+  protected void getOAuthToken(final AccountAuthenticatorResponse response, final AndroidFxAccount fxAccount, final String scope) throws NetworkErrorException {
+    Logger.info(LOG_TAG, "Fetching oauth token with scope: " + scope);
+
+    final Responder responder = new Responder(response, fxAccount.getAndroidAccount());
+
+    final String oauthServerUri = FxAccountConstants.DEFAULT_OAUTH_SERVER_ENDPOINT;
+    final String audience;
+    try {
+      audience = FxAccountUtils.getAudienceForURL(oauthServerUri); // The assertion gets traded in for an oauth bearer token.
+    } catch (Exception e) {
+      Logger.warn(LOG_TAG, "Got exception fetching oauth token.", e);
+      responder.fail(e);
+      return;
+    }
+
+    final FxAccountLoginStateMachine stateMachine = new FxAccountLoginStateMachine();
+
+    stateMachine.advance(fxAccount.getState(), StateLabel.Married, new FxADefaultLoginStateMachineDelegate(context, fxAccount) {
+      @Override
+      public void handleNotMarried(State state) {
+        final String message = "Cannot fetch oauth token from state: " + state.getStateLabel();
+        Logger.warn(LOG_TAG, message);
+        responder.fail(new RuntimeException(message));
+      }
+
+      @Override
+      public void handleMarried(final Married married) {
+        final String assertion;
+        try {
+          assertion = married.generateAssertion(audience, JSONWebTokenUtils.DEFAULT_ASSERTION_ISSUER);
+          if (FxAccountUtils.LOG_PERSONAL_INFORMATION) {
+            JSONWebTokenUtils.dumpAssertion(assertion);
+          }
+        } catch (Exception e) {
+          Logger.warn(LOG_TAG, "Got exception fetching oauth token.", e);
+          responder.fail(e);
+          return;
+        }
+
+        final FxAccountOAuthClient10 oauthClient = new FxAccountOAuthClient10(oauthServerUri, executor);
+        Logger.debug(LOG_TAG, "OAuth fetch for scope: " + scope);
+        oauthClient.authorization(FxAccountConstants.OAUTH_CLIENT_ID_FENNEC, assertion, null, scope, new RequestDelegate<FxAccountOAuthClient10.AuthorizationResponse>() {
+          @Override
+          public void handleSuccess(AuthorizationResponse result) {
+            Logger.debug(LOG_TAG, "OAuth success.");
+            FxAccountUtils.pii(LOG_TAG, "Fetched oauth token: " + result.access_token);
+            responder.succeed(result.access_token);
+          }
+
+          @Override
+          public void handleFailure(FxAccountAbstractClientRemoteException e) {
+            Logger.error(LOG_TAG, "OAuth failure.", e);
+            if (e.isInvalidAuthentication()) {
+              // We were married, generated an assertion, and our assertion was rejected by the
+              // oauth client. If it's a 401, we probably have a stale certificate.  If instead of
+              // a stale certificate we have bad credentials, the state machine will fail to sign
+              // our public key and drive us back to Separated.
+              fxAccount.setState(married.makeCohabitingState());
+            }
+            responder.fail(e);
+          }
+
+          @Override
+          public void handleError(Exception e) {
+            Logger.error(LOG_TAG, "OAuth error.", e);
+            responder.fail(e);
+          }
+        });
+      }
+    });
+  }
+
   @Override
   public Bundle getAuthToken(final AccountAuthenticatorResponse response,
       final Account account, final String authTokenType, final Bundle options)
           throws NetworkErrorException {
-    Logger.debug(LOG_TAG, "getAuthToken");
+    Logger.debug(LOG_TAG, "getAuthToken: " + authTokenType);
 
+    // If we have a cached authToken, hand it over.
+    final String cachedAuthToken = AccountManager.get(context).peekAuthToken(account, authTokenType);
+    if (cachedAuthToken != null && !cachedAuthToken.isEmpty()) {
+      Logger.info(LOG_TAG, "Return cached token.");
+      final Bundle result = new Bundle();
+      result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
+      result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type);
+      result.putString(AccountManager.KEY_AUTHTOKEN, cachedAuthToken);
+      return result;
+    }
+
+    // If we're asked for an oauth::scope token, try to generate one.
+    final String oauthPrefix = "oauth::";
+    if (authTokenType != null && authTokenType.startsWith(oauthPrefix)) {
+      final String scope = authTokenType.substring(oauthPrefix.length());
+      final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
+      getOAuthToken(response, fxAccount, scope);
+      return null;
+    }
+
+    // Otherwise, fail.
     Logger.warn(LOG_TAG, "Returning null bundle for getAuthToken.");
 
     return null;
   }
 
   @Override
   public String getAuthTokenLabel(String authTokenType) {
     Logger.debug(LOG_TAG, "getAuthTokenLabel");
--- a/mobile/android/base/fxa/receivers/FxAccountDeletedService.java
+++ b/mobile/android/base/fxa/receivers/FxAccountDeletedService.java
@@ -63,16 +63,20 @@ public class FxAccountDeletedService ext
     deletePickle(context);
 
     // Delete client database and non-local tabs.
     Logger.info(LOG_TAG, "Deleting the entire clients database and non-local tabs");
     FennecTabsRepository.deleteNonLocalClientsAndTabs(context);
 
     // Remove any displayed notifications.
     new FxAccountNotificationManager(FxAccountSyncAdapter.NOTIFICATION_ID).clear(context);
+
+    // Bug 1147275: Delete cached oauth tokens. There's no way to query all
+    // oauth tokens from Android, so this is tricky to do comprehensively. We
+    // can query, individually, for specific oauth tokens to delete, however.
   }
 
   public static void deletePickle(final Context context) {
     try {
       AccountPickler.deletePickle(context, FxAccountConstants.ACCOUNT_PICKLE_FILENAME);
     } catch (Exception e) {
       // This should never happen, but we really don't want to die in a background thread.
       Logger.warn(LOG_TAG, "Got exception deleting saved pickle file; ignoring.", e);
--- a/mobile/android/base/fxa/sync/FxAccountNotificationManager.java
+++ b/mobile/android/base/fxa/sync/FxAccountNotificationManager.java
@@ -60,17 +60,17 @@ public class FxAccountNotificationManage
    * Reflect new Firefox Account state to the notification manager: show or hide
    * notifications reflecting the state of a Firefox Account.
    *
    * @param context
    *          Android context.
    * @param fxAccount
    *          Firefox Account to reflect to the notification manager.
    */
-  protected void update(Context context, AndroidFxAccount fxAccount) {
+  public void update(Context context, AndroidFxAccount fxAccount) {
     final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
 
     final State state = fxAccount.getState();
     final Action action = state.getNeededAction();
     if (action == Action.None) {
       Logger.info(LOG_TAG, "State " + state.getStateLabel() + " needs no action; cancelling any existing notification.");
       notificationManager.cancel(notificationId);
       return;
--- a/mobile/android/base/fxa/sync/FxAccountSyncAdapter.java
+++ b/mobile/android/base/fxa/sync/FxAccountSyncAdapter.java
@@ -1,43 +1,37 @@
 /* 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/. */
 
 package org.mozilla.gecko.fxa.sync;
 
 import java.net.URI;
 import java.net.URISyntaxException;
-import java.security.NoSuchAlgorithmException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 
 import org.mozilla.gecko.background.common.log.Logger;
-import org.mozilla.gecko.background.fxa.FxAccountClient;
-import org.mozilla.gecko.background.fxa.FxAccountClient20;
 import org.mozilla.gecko.background.fxa.FxAccountUtils;
 import org.mozilla.gecko.background.fxa.SkewHandler;
-import org.mozilla.gecko.browserid.BrowserIDKeyPair;
 import org.mozilla.gecko.browserid.JSONWebTokenUtils;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
 import org.mozilla.gecko.fxa.FxAccountConstants;
 import org.mozilla.gecko.fxa.authenticator.AccountPickler;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.authenticator.FxADefaultLoginStateMachineDelegate;
 import org.mozilla.gecko.fxa.authenticator.FxAccountAuthenticator;
 import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine;
-import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.LoginStateMachineDelegate;
-import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition;
 import org.mozilla.gecko.fxa.login.Married;
 import org.mozilla.gecko.fxa.login.State;
 import org.mozilla.gecko.fxa.login.State.StateLabel;
-import org.mozilla.gecko.fxa.login.StateFactory;
 import org.mozilla.gecko.sync.BackoffHandler;
 import org.mozilla.gecko.sync.GlobalSession;
 import org.mozilla.gecko.sync.PrefsBackoffHandler;
 import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate;
 import org.mozilla.gecko.sync.SyncConfiguration;
 import org.mozilla.gecko.sync.ThreadPool;
 import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
@@ -451,67 +445,39 @@ public class FxAccountSyncAdapter extend
       }
 
       final SchedulePolicy schedulePolicy = new FxAccountSchedulePolicy(context, fxAccount);
 
       // Set a small scheduled 'backoff' to rate-limit the next sync,
       // and extend the background delay even further into the future.
       schedulePolicy.configureBackoffMillisBeforeSyncing(rateLimitBackoffHandler, backgroundBackoffHandler);
 
-      final String authServerEndpoint = fxAccount.getAccountServerURI();
       final String tokenServerEndpoint = fxAccount.getTokenServerURI();
       final URI tokenServerEndpointURI = new URI(tokenServerEndpoint);
       final String audience = FxAccountUtils.getAudienceForURL(tokenServerEndpoint);
 
-      // TODO: why doesn't the loginPolicy extract the audience from the account?
-      final FxAccountClient client = new FxAccountClient20(authServerEndpoint, executor);
       final FxAccountLoginStateMachine stateMachine = new FxAccountLoginStateMachine();
-      stateMachine.advance(state, StateLabel.Married, new LoginStateMachineDelegate() {
-        @Override
-        public FxAccountClient getClient() {
-          return client;
-        }
-
+      stateMachine.advance(state, StateLabel.Married, new FxADefaultLoginStateMachineDelegate(context, fxAccount) {
         @Override
-        public long getCertificateDurationInMilliseconds() {
-          return 12 * 60 * 60 * 1000;
-        }
-
-        @Override
-        public long getAssertionDurationInMilliseconds() {
-          return 15 * 60 * 1000;
-        }
-
-        @Override
-        public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException {
-          return StateFactory.generateKeyPair();
-        }
-
-        @Override
-        public void handleTransition(Transition transition, State state) {
-          Logger.info(LOG_TAG, "handleTransition: " + transition + " to " + state.getStateLabel());
+        public void handleNotMarried(State notMarried) {
+          Logger.info(LOG_TAG, "handleNotMarried: in " + notMarried.getStateLabel());
+          schedulePolicy.onHandleFinal(notMarried.getNeededAction());
+          syncDelegate.handleCannotSync(notMarried);
         }
 
         private boolean shouldRequestToken(final BackoffHandler tokenBackoffHandler, final Bundle extras) {
           return shouldPerformSync(tokenBackoffHandler, "token", extras);
         }
 
         @Override
-        public void handleFinal(State state) {
-          Logger.info(LOG_TAG, "handleFinal: in " + state.getStateLabel());
-          fxAccount.setState(state);
-          schedulePolicy.onHandleFinal(state.getNeededAction());
-          notificationManager.update(context, fxAccount);
+        public void handleMarried(Married married) {
+          schedulePolicy.onHandleFinal(married.getNeededAction());
+          Logger.info(LOG_TAG, "handleMarried: in " + married.getStateLabel());
+
           try {
-            if (state.getStateLabel() != StateLabel.Married) {
-              syncDelegate.handleCannotSync(state);
-              return;
-            }
-
-            final Married married = (Married) state;
             final String assertion = married.generateAssertion(audience, JSONWebTokenUtils.DEFAULT_ASSERTION_ISSUER);
 
             /*
              * At this point we're in the correct state to sync, and we're ready to fetch
              * a token and do some work.
              *
              * But first we need to do two things:
              * 1. Check to see whether we're in a backoff situation for the token server.
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/reading/ReadingListInvalidAuthenticationException.java
@@ -0,0 +1,18 @@
+/* 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/. */
+
+package org.mozilla.gecko.reading;
+
+import org.mozilla.gecko.sync.net.MozResponse;
+
+public class ReadingListInvalidAuthenticationException extends Exception {
+  private static final long serialVersionUID = 7112459541558266597L;
+
+  public final MozResponse response;
+
+  public ReadingListInvalidAuthenticationException(MozResponse response) {
+    super();
+    this.response = response;
+  }
+}
--- a/mobile/android/base/reading/ReadingListSyncAdapter.java
+++ b/mobile/android/base/reading/ReadingListSyncAdapter.java
@@ -1,49 +1,33 @@
 /* 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/. */
 
 package org.mozilla.gecko.reading;
 
 import java.net.URI;
 import java.net.URISyntaxException;
-import java.security.NoSuchAlgorithmException;
 import java.util.Collection;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 
 import org.mozilla.gecko.background.common.PrefsBranch;
 import org.mozilla.gecko.background.common.log.Logger;
-import org.mozilla.gecko.background.fxa.FxAccountClient;
-import org.mozilla.gecko.background.fxa.FxAccountClient20;
 import org.mozilla.gecko.background.fxa.FxAccountUtils;
-import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient.RequestDelegate;
-import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException.FxAccountAbstractClientRemoteException;
-import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10;
-import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10.AuthorizationResponse;
-import org.mozilla.gecko.browserid.BrowserIDKeyPair;
-import org.mozilla.gecko.browserid.JSONWebTokenUtils;
 import org.mozilla.gecko.db.BrowserContract.ReadingListItems;
-import org.mozilla.gecko.fxa.FxAccountConstants;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
-import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine;
-import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.LoginStateMachineDelegate;
-import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition;
-import org.mozilla.gecko.fxa.login.Married;
-import org.mozilla.gecko.fxa.login.State;
-import org.mozilla.gecko.fxa.login.State.StateLabel;
-import org.mozilla.gecko.fxa.login.StateFactory;
 import org.mozilla.gecko.fxa.sync.FxAccountSyncDelegate;
 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
 import org.mozilla.gecko.sync.net.BearerAuthHeaderProvider;
 
 import android.accounts.Account;
+import android.accounts.AccountManager;
 import android.content.AbstractThreadedSyncAdapter;
 import android.content.ContentProviderClient;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.SharedPreferences;
 import android.content.SyncResult;
 import android.os.Bundle;
 
@@ -54,33 +38,37 @@ public class ReadingListSyncAdapter exte
   private static final long TIMEOUT_SECONDS = 60;
   protected final ExecutorService executor;
 
   public ReadingListSyncAdapter(Context context, boolean autoInitialize) {
     super(context, autoInitialize);
     this.executor = Executors.newSingleThreadExecutor();
   }
 
-
-  static final class SyncAdapterSynchronizerDelegate implements ReadingListSynchronizerDelegate {
+  protected static abstract class SyncAdapterSynchronizerDelegate implements ReadingListSynchronizerDelegate {
     private final FxAccountSyncDelegate syncDelegate;
     private final ContentProviderClient cpc;
     private final SyncResult result;
 
     SyncAdapterSynchronizerDelegate(FxAccountSyncDelegate syncDelegate,
                                     ContentProviderClient cpc,
                                     SyncResult result) {
       this.syncDelegate = syncDelegate;
       this.cpc = cpc;
       this.result = result;
     }
 
+    abstract public void onInvalidAuthentication();
+
     @Override
     public void onUnableToSync(Exception e) {
       Logger.warn(LOG_TAG, "Unable to sync.", e);
+      if (e instanceof ReadingListInvalidAuthenticationException) {
+        onInvalidAuthentication();
+      }
       cpc.release();
       syncDelegate.handleError(e);
     }
 
     @Override
     public void onDeletionsUploadComplete() {
       Logger.debug(LOG_TAG, "Step: onDeletionsUploadComplete");
       this.result.stats.numEntries += 1;   // TODO: Bug 1140809.
@@ -115,168 +103,98 @@ public class ReadingListSyncAdapter exte
     @Override
     public void onComplete() {
       Logger.info(LOG_TAG, "Reading list synchronization complete.");
       cpc.release();
       syncDelegate.handleSuccess();
     }
   }
 
+  private void syncWithAuthorization(final Context context,
+                                     final Account account,
+                                     final SyncResult syncResult,
+                                     final FxAccountSyncDelegate syncDelegate,
+                                     final String authToken,
+                                     final SharedPreferences sharedPrefs,
+                                     final Bundle extras) {
+    final AuthHeaderProvider auth = new BearerAuthHeaderProvider(authToken);
+
+    final String endpointString = ReadingListConstants.DEFAULT_PROD_ENDPOINT;
+    final URI endpoint;
+    Logger.info(LOG_TAG, "Syncing reading list against " + endpointString);
+    try {
+      endpoint = new URI(endpointString);
+    } catch (URISyntaxException e) {
+      // Should never happen.
+      Logger.error(LOG_TAG, "Unexpected malformed URI for reading list service: " + endpointString);
+      syncDelegate.handleError(e);
+      return;
+    }
+
+    final PrefsBranch branch = new PrefsBranch(sharedPrefs, "readinglist.");
+    final ReadingListClient remote = new ReadingListClient(endpoint, auth);
+    final ContentProviderClient cpc = getContentProviderClient(context); // Released by the inner SyncAdapterSynchronizerDelegate.
+
+    final LocalReadingListStorage local = new LocalReadingListStorage(cpc);
+    String localName = branch.getString(PREF_LOCAL_NAME, null);
+    if (localName == null) {
+      localName = FxAccountUtils.defaultClientName(context);
+    }
+
+    // Make sure DB rows don't refer to placeholder values.
+    local.updateLocalNames(localName);
+
+    final ReadingListSynchronizer synchronizer = new ReadingListSynchronizer(branch, remote, local);
+
+    synchronizer.syncAll(new SyncAdapterSynchronizerDelegate(syncDelegate, cpc, syncResult) {
+      @Override
+      public void onInvalidAuthentication() {
+        // The reading list server rejected our oauth token! Invalidate it. Next
+        // time through, we'll request a new one, which will drive the login
+        // state machine, produce a new assertion, and eventually a fresh token.
+        Logger.info(LOG_TAG, "Invalidating oauth token after 401!");
+        AccountManager.get(context).invalidateAuthToken(account.type, authToken);
+      }
+    });
+    // TODO: backoffs, and everything else handled by a SessionCallback.
+  }
+
   @Override
   public void onPerformSync(final Account account, final Bundle extras, final String authority, final ContentProviderClient provider, final SyncResult syncResult) {
     Logger.setThreadLogTag(ReadingListConstants.GLOBAL_LOG_TAG);
     Logger.resetLogging();
 
     final Context context = getContext();
     final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
 
-    // If this sync was triggered by user action, this will be true.
-    final boolean isImmediate = (extras != null) &&
-        (extras.getBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, false) ||
-            extras.getBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, false));
-
     final CountDownLatch latch = new CountDownLatch(1);
     final FxAccountSyncDelegate syncDelegate = new FxAccountSyncDelegate(latch, syncResult, fxAccount);
+
+    final AccountManager accountManager = AccountManager.get(context);
+    // If we have an auth failure that requires user intervention, FxA will show system
+    // notifications prompting the user to re-connect as it advances the internal account state.
+    // true causes the auth token fetch to return null on failure immediately, rather than doing
+    // Mysterious Internal Work to try to get the token.
+    final boolean notifyAuthFailure = true;
     try {
-      final State state;
-      try {
-        state = fxAccount.getState();
-      } catch (Exception e) {
-        Logger.error(LOG_TAG, "Unable to sync.", e);
-        return;
+      final String authToken = accountManager.blockingGetAuthToken(account, ReadingListConstants.AUTH_TOKEN_TYPE, notifyAuthFailure);
+      if (authToken == null) {
+        throw new RuntimeException("Couldn't get oauth token!  Aborting sync.");
       }
-
-      final String oauthServerUri = FxAccountConstants.STAGE_OAUTH_SERVER_ENDPOINT;
-      final String authServerEndpoint = fxAccount.getAccountServerURI();
-      final String audience = FxAccountUtils.getAudienceForURL(oauthServerUri); // The assertion gets traded in for an oauth bearer token.
-
       final SharedPreferences sharedPrefs = fxAccount.getReadingListPrefs();
-      final FxAccountClient client = new FxAccountClient20(authServerEndpoint, executor);
-      final FxAccountLoginStateMachine stateMachine = new FxAccountLoginStateMachine();
-
-      stateMachine.advance(state, StateLabel.Married, new LoginStateMachineDelegate() {
-        @Override
-        public FxAccountClient getClient() {
-          return client;
-        }
-
-        @Override
-        public long getCertificateDurationInMilliseconds() {
-          return 12 * 60 * 60 * 1000;
-        }
-
-        @Override
-        public long getAssertionDurationInMilliseconds() {
-          return 15 * 60 * 1000;
-        }
-
-        @Override
-        public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException {
-          return StateFactory.generateKeyPair();
-        }
-
-        @Override
-        public void handleTransition(Transition transition, State state) {
-          Logger.info(LOG_TAG, "handleTransition: " + transition + " to " + state.getStateLabel());
-        }
-
-        @Override
-        public void handleFinal(State state) {
-          Logger.info(LOG_TAG, "handleFinal: in " + state.getStateLabel());
-          fxAccount.setState(state);
-
-          // TODO: scheduling, notifications.
-          try {
-            if (state.getStateLabel() != StateLabel.Married) {
-              syncDelegate.handleCannotSync(state);
-              return;
-            }
-
-            final Married married = (Married) state;
-            final String assertion = married.generateAssertion(audience, JSONWebTokenUtils.DEFAULT_ASSERTION_ISSUER);
-            JSONWebTokenUtils.dumpAssertion(assertion);
-
-            final String clientID = FxAccountConstants.OAUTH_CLIENT_ID_FENNEC;
-            final String scope = ReadingListConstants.OAUTH_SCOPE_READINGLIST;
-            syncWithAssertion(clientID, scope, assertion, sharedPrefs, extras);
-          } catch (Exception e) {
-            syncDelegate.handleError(e);
-            return;
-          }
-        }
-
-        private void syncWithAssertion(final String client_id, final String scope, final String assertion,
-                                       final SharedPreferences sharedPrefs, final Bundle extras) {
-          final FxAccountOAuthClient10 oauthClient = new FxAccountOAuthClient10(oauthServerUri, executor);
-          Logger.debug(LOG_TAG, "OAuth fetch.");
-          oauthClient.authorization(client_id, assertion, null, scope, new RequestDelegate<FxAccountOAuthClient10.AuthorizationResponse>() {
-            @Override
-            public void handleSuccess(AuthorizationResponse result) {
-              Logger.debug(LOG_TAG, "OAuth success.");
-              syncWithAuthorization(result, sharedPrefs, extras);
-            }
-
-            @Override
-            public void handleFailure(FxAccountAbstractClientRemoteException e) {
-              Logger.error(LOG_TAG, "OAuth failure.", e);
-              syncDelegate.handleError(e);
-            }
-
-            @Override
-            public void handleError(Exception e) {
-              Logger.error(LOG_TAG, "OAuth error.", e);
-              syncDelegate.handleError(e);
-            }
-          });
-        }
-
-        private void syncWithAuthorization(AuthorizationResponse authResponse,
-                                           SharedPreferences sharedPrefs,
-                                           Bundle extras) {
-          final AuthHeaderProvider auth = new BearerAuthHeaderProvider(authResponse.access_token);
-
-          final String endpointString = ReadingListConstants.DEFAULT_DEV_ENDPOINT;
-          final URI endpoint;
-          Logger.info(LOG_TAG, "XXX Syncing to " + endpointString);
-          try {
-            endpoint = new URI(endpointString);
-          } catch (URISyntaxException e) {
-            // Should never happen.
-            Logger.error(LOG_TAG, "Unexpected malformed URI for reading list service: " + endpointString);
-            syncDelegate.handleError(e);
-            return;
-          }
-
-          final PrefsBranch branch = new PrefsBranch(sharedPrefs, "readinglist.");
-          final ReadingListClient remote = new ReadingListClient(endpoint, auth);
-          final ContentProviderClient cpc = getContentProviderClient(context);     // TODO: make sure I'm always released!
-
-          final LocalReadingListStorage local = new LocalReadingListStorage(cpc);
-          String localName = branch.getString(PREF_LOCAL_NAME, null);
-          if (localName == null) {
-            localName = FxAccountUtils.defaultClientName(context);
-          }
-
-          // Make sure DB rows don't refer to placeholder values.
-          local.updateLocalNames(localName);
-
-          final ReadingListSynchronizer synchronizer = new ReadingListSynchronizer(branch, remote, local);
-
-          synchronizer.syncAll(new SyncAdapterSynchronizerDelegate(syncDelegate, cpc, syncResult));
-          // TODO: backoffs, and everything else handled by a SessionCallback.
-        }
-      });
+      syncWithAuthorization(context, account, syncResult, syncDelegate, authToken, sharedPrefs, extras);
 
       latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS);
       Logger.info(LOG_TAG, "Reading list sync done.");
-
     } catch (Exception e) {
+      // We can get lots of exceptions here; handle them uniformly.
       Logger.error(LOG_TAG, "Got error syncing.", e);
       syncDelegate.handleError(e);
     }
+
     /*
      * TODO:
      * * Account error notifications. How do we avoid these overlapping with Sync?
      * * Pickling. How do we avoid pickling twice if you use both Sync and RL?
      */
 
     /*
      * TODO:
@@ -285,15 +203,14 @@ public class ReadingListSyncAdapter exte
      * * Syncing.
      * * Error handling.
      * * Backoff and retry-after.
      * * Sync scheduling.
      * * Forcing syncs/interactive use.
      */
   }
 
-
   private ContentProviderClient getContentProviderClient(Context context) {
     final ContentResolver contentResolver = context.getContentResolver();
     final ContentProviderClient client = contentResolver.acquireContentProviderClient(ReadingListItems.CONTENT_URI);
     return client;
   }
 }
--- a/mobile/android/base/reading/ReadingListSynchronizer.java
+++ b/mobile/android/base/reading/ReadingListSynchronizer.java
@@ -769,17 +769,21 @@ public class ReadingListSynchronizer {
         delegate.fail(error);
       }
 
       @Override
       public void onFailure(MozResponse response) {
         final int statusCode = response.getStatusCode();
         Logger.error(LOG_TAG, "Download failed. since = " + since + ". Response: " + statusCode);
         response.logResponseBody(LOG_TAG);
-        delegate.fail();
+        if (response.isInvalidAuthentication()) {
+          delegate.fail(new ReadingListInvalidAuthenticationException(response));
+        } else {
+          delegate.fail();
+        }
       }
 
       @Override
       public void onComplete(ReadingListResponse response) {
         long lastModified = response.getLastModified();
         Logger.info(LOG_TAG, "Server last modified: " + lastModified);
         try {
           postDownload.finish();
--- a/mobile/android/base/sync/net/MozResponse.java
+++ b/mobile/android/base/sync/net/MozResponse.java
@@ -14,16 +14,17 @@ import java.util.Scanner;
 import org.json.simple.parser.ParseException;
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.NonObjectJSONException;
 
 import ch.boye.httpclientandroidlib.Header;
 import ch.boye.httpclientandroidlib.HttpEntity;
 import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.HttpStatus;
 import ch.boye.httpclientandroidlib.impl.cookie.DateParseException;
 import ch.boye.httpclientandroidlib.impl.cookie.DateUtils;
 
 public class MozResponse {
   private static final String LOG_TAG = "MozResponse";
 
   private static final String HEADER_RETRY_AFTER = "retry-after";
 
@@ -37,16 +38,20 @@ public class MozResponse {
   public int getStatusCode() {
     return this.response.getStatusLine().getStatusCode();
   }
 
   public boolean wasSuccessful() {
     return this.getStatusCode() == 200;
   }
 
+  public boolean isInvalidAuthentication() {
+    return this.getStatusCode() == HttpStatus.SC_UNAUTHORIZED;
+  }
+
   /**
    * Fetch the content type of the HTTP response body.
    *
    * @return a <code>Header</code> instance, or <code>null</code> if there was
    *         no body or no valid Content-Type.
    */
   public Header getContentType() {
     HttpEntity entity = this.response.getEntity();