Repository: firebase/firebase-admin-java
Pull request #255 (2019-02-25 04:51:14)

HTTP Retry Support
We are in the process of implementing HTTP retry support across all Admin SDKs. Specifically, we want to retry an HTTP request in the following situations:

* All I/O errors (i.e. network errors, socket errors etc)
* HTTP 500 and 503 errors

This PR lays the foundation for supporting HTTP retries in the Java Admin SDK. However, we are not enabling retries for any of the APIs just yet. This will need some more refactoring in each of the modules, and therefore we will do it later in an incremental fashion.

hiranya911: Thanks @schmidt-sebastian. I've made some changes and responded to your comments. In general, I don't see how to reuse gax retry mechanism in our code, since we don't use gax. But I've managed to make most of the other improvements suggested.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e669a28..cea3b25 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Unreleased
 
+-
+
+# v6.8.0
+
 - [added] Implemented new `sendAll()` and `sendMulticast()` APIs in
   `FirebaseMessaging`.
 - [changed] Removed org.json dependency and replaced with com.google.code.gson.
diff --git a/NOTICE.txt b/NOTICE.txt
new file mode 100644
index 0000000..b6c5d02
--- /dev/null
+++ b/NOTICE.txt
@@ -0,0 +1,5 @@
+Firebase Admin Java SDK
+Copyright 2019 Google Inc.
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
diff --git a/pom.xml b/pom.xml
index 0fcfe20..27e137a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -19,7 +19,7 @@
 
     <groupId>com.google.firebase</groupId>
     <artifactId>firebase-admin</artifactId>
-    <version>6.7.1-SNAPSHOT</version>
+    <version>6.8.1-SNAPSHOT</version>
     <packaging>jar</packaging>
 
     <name>firebase-admin</name>
diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuth.java b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
index 843c42f..0e02f83 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseAuth.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
@@ -25,6 +25,8 @@ import com.google.api.client.util.Clock;
 import com.google.api.core.ApiFuture;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.ImplFirebaseTrampolines;
 import com.google.firebase.auth.FirebaseUserManager.EmailLinkType;
@@ -34,17 +36,15 @@ import com.google.firebase.auth.ListUsersPage.PageFactory;
 import com.google.firebase.auth.UserRecord.CreateRequest;
 import com.google.firebase.auth.UserRecord.UpdateRequest;
 import com.google.firebase.auth.internal.FirebaseTokenFactory;
-import com.google.firebase.auth.internal.FirebaseTokenVerifier;
-import com.google.firebase.auth.internal.KeyManagers;
 import com.google.firebase.internal.CallableOperation;
 import com.google.firebase.internal.FirebaseService;
 import com.google.firebase.internal.NonNull;
 import com.google.firebase.internal.Nullable;
+
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicReference;
 
 /**
  * This class is the entry point for all server-side Firebase Authentication actions.
@@ -56,40 +56,27 @@ import java.util.concurrent.atomic.AtomicReference;
  */
 public class FirebaseAuth {
 
+  private static final String SERVICE_ID = FirebaseAuth.class.getName();
+
   private static final String ERROR_CUSTOM_TOKEN = "ERROR_CUSTOM_TOKEN";
-  private static final String ERROR_INVALID_ID_TOKEN = "ERROR_INVALID_CREDENTIAL";
-  private static final String ERROR_INVALID_SESSION_COOKIE = "ERROR_INVALID_COOKIE";
 
-  private final Clock clock;
+  private final Object lock = new Object();
+  private final AtomicBoolean destroyed = new AtomicBoolean(false);
 
   private final FirebaseApp firebaseApp;
-  private final KeyManagers keyManagers;
-  private final String projectId;
+  private final Supplier<FirebaseTokenFactory> tokenFactory;
+  private final Supplier<? extends FirebaseTokenVerifier> idTokenVerifier;
+  private final Supplier<? extends FirebaseTokenVerifier> cookieVerifier;
   private final JsonFactory jsonFactory;
   private final FirebaseUserManager userManager;
-  private final AtomicReference<FirebaseTokenFactory> tokenFactory;
-  private final AtomicBoolean destroyed;
-  private final Object lock;
-
-  private FirebaseAuth(FirebaseApp firebaseApp) {
-    this(firebaseApp, KeyManagers.getDefault(firebaseApp, Clock.SYSTEM), Clock.SYSTEM);
-  }
 
-  /**
-   * Constructor for injecting a GooglePublicKeysManager, which is used to verify tokens are
-   * correctly signed. This should only be used for testing to override the default key manager.
-   */
-  @VisibleForTesting
-  FirebaseAuth(FirebaseApp firebaseApp, KeyManagers keyManagers, Clock clock) {
-    this.firebaseApp = checkNotNull(firebaseApp);
-    this.keyManagers = checkNotNull(keyManagers);
-    this.clock = checkNotNull(clock);
-    this.projectId = ImplFirebaseTrampolines.getProjectId(firebaseApp);
+  private FirebaseAuth(Builder builder) {
+    this.firebaseApp = checkNotNull(builder.firebaseApp);
+    this.tokenFactory = threadSafeMemoize(builder.tokenFactory);
+    this.idTokenVerifier = threadSafeMemoize(builder.idTokenVerifier);
+    this.cookieVerifier = threadSafeMemoize(builder.cookieVerifier);
     this.jsonFactory = firebaseApp.getOptions().getJsonFactory();
     this.userManager = new FirebaseUserManager(firebaseApp);
-    this.tokenFactory = new AtomicReference<>(null);
-    this.destroyed = new AtomicBoolean(false);
-    this.lock = new Object();
   }
 
   /**
@@ -224,40 +211,23 @@ public class FirebaseAuth {
   private CallableOperation<FirebaseToken, FirebaseAuthException> verifySessionCookieOp(
       final String cookie, final boolean checkRevoked) {
     checkNotDestroyed();
-    checkState(!Strings.isNullOrEmpty(projectId),
-        "Must initialize FirebaseApp with a project ID to call verifySessionCookie()");
+    checkArgument(!Strings.isNullOrEmpty(cookie), "Session cookie must not be null or empty");
+    final FirebaseTokenVerifier sessionCookieVerifier = getSessionCookieVerifier(checkRevoked);
     return new CallableOperation<FirebaseToken, FirebaseAuthException>() {
       @Override
       public FirebaseToken execute() throws FirebaseAuthException {
-        FirebaseTokenVerifier firebaseTokenVerifier =
-            FirebaseTokenVerifier.createSessionCookieVerifier(projectId, keyManagers, clock);
-        FirebaseToken firebaseToken;
-        try {
-          firebaseToken = FirebaseToken.parse(jsonFactory, cookie);
-        } catch (IOException e) {
-          throw new FirebaseAuthException(ERROR_INVALID_SESSION_COOKIE,
-              "Failed to parse cookie", e);
-        }
-        // This will throw a FirebaseAuthException with details on how the token is invalid.
-        firebaseTokenVerifier.verifyTokenAndSignature(firebaseToken.getToken());
-
-        if (checkRevoked) {
-          checkRevoked(firebaseToken, "session cookie",
-              FirebaseUserManager.SESSION_COOKIE_REVOKED_ERROR);
-        }
-        return firebaseToken;
+        return sessionCookieVerifier.verifyToken(cookie);
       }
     };
   }
 
-  private void checkRevoked(
-      FirebaseToken firebaseToken, String label, String errorCode) throws FirebaseAuthException {
-    String uid = firebaseToken.getUid();
-    UserRecord user = userManager.getUserById(uid);
-    long issuedAt = (long) firebaseToken.getClaims().get("iat");
-    if (user.getTokensValidAfterTimestamp() > issuedAt * 1000) {
-      throw new FirebaseAuthException(errorCode, "Firebase " + label + " revoked");
+  @VisibleForTesting
+  FirebaseTokenVerifier getSessionCookieVerifier(boolean checkRevoked) {
+    FirebaseTokenVerifier verifier = cookieVerifier.get();
+    if (checkRevoked) {
+      verifier = RevocationCheckDecorator.decorateSessionCookieVerifier(verifier, userManager);
     }
+    return verifier;
   }
 
   /**
@@ -355,7 +325,7 @@ public class FirebaseAuth {
       final String uid, final Map<String, Object> developerClaims) {
     checkNotDestroyed();
     checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty");
-    final FirebaseTokenFactory tokenFactory = ensureTokenFactory();
+    final FirebaseTokenFactory tokenFactory = this.tokenFactory.get();
     return new CallableOperation<String, FirebaseAuthException>() {
       @Override
       public String execute() throws FirebaseAuthException {
@@ -369,29 +339,6 @@ public class FirebaseAuth {
     };
   }
 
-  private FirebaseTokenFactory ensureTokenFactory() {
-    FirebaseTokenFactory result = this.tokenFactory.get();
-    if (result == null) {
-      synchronized (lock) {
-        result = this.tokenFactory.get();
-        if (result == null) {
-          try {
-            result = FirebaseTokenFactory.fromApp(firebaseApp, clock);
-            this.tokenFactory.set(result);
-          } catch (IOException e) {
-            throw new IllegalStateException(
-                "Failed to initialize FirebaseTokenFactory. Make sure to initialize the SDK "
-                    + "with service account credentials or specify a service account "
-                    + "ID with iam.serviceAccounts.signBlob permission. Please refer to "
-                    + "https://firebase.google.com/docs/auth/admin/create-custom-tokens for more "
-                    + "details on creating custom tokens.", e);
-          }
-        }
-      }
-    }
-    return result;
-  }
-
   /**
    * Parses and verifies a Firebase ID Token.
    *
@@ -472,31 +419,24 @@ public class FirebaseAuth {
       final String token, final boolean checkRevoked) {
     checkNotDestroyed();
     checkArgument(!Strings.isNullOrEmpty(token), "ID token must not be null or empty");
-    checkArgument(!Strings.isNullOrEmpty(projectId),
-        "Must initialize FirebaseApp with a project ID to call verifyIdToken()");
+    final FirebaseTokenVerifier verifier = getIdTokenVerifier(checkRevoked);
     return new CallableOperation<FirebaseToken, FirebaseAuthException>() {
       @Override
       protected FirebaseToken execute() throws FirebaseAuthException {
-        FirebaseTokenVerifier firebaseTokenVerifier =
-            FirebaseTokenVerifier.createIdTokenVerifier(projectId, keyManagers, clock);
-        FirebaseToken firebaseToken;
-        try {
-          firebaseToken = FirebaseToken.parse(jsonFactory, token);
-        } catch (IOException e) {
-          throw new FirebaseAuthException(ERROR_INVALID_ID_TOKEN, "Failed to parse token", e);
-        }
-
-        // This will throw a FirebaseAuthException with details on how the token is invalid.
-        firebaseTokenVerifier.verifyTokenAndSignature(firebaseToken.getToken());
-
-        if (checkRevoked) {
-          checkRevoked(firebaseToken, "auth token", FirebaseUserManager.ID_TOKEN_REVOKED_ERROR);
-        }       
-        return firebaseToken;
+        return verifier.verifyToken(token);
       }
     };
   }
 
+  @VisibleForTesting
+  FirebaseTokenVerifier getIdTokenVerifier(boolean checkRevoked) {
+    FirebaseTokenVerifier verifier = idTokenVerifier.get();
+    if (checkRevoked) {
+      verifier = RevocationCheckDecorator.decorateIdTokenVerifier(verifier, userManager);
+    }
+    return verifier;
+  }
+
   /**
    * Revokes all refresh tokens for the specified user.
    *
@@ -705,13 +645,12 @@ public class FirebaseAuth {
    * @throws IllegalArgumentException If the specified page token is empty, or max results value
    *     is invalid.
    */
-  public ApiFuture<ListUsersPage> listUsersAsync(
-      @Nullable final String pageToken, final int maxResults) {
+  public ApiFuture<ListUsersPage> listUsersAsync(@Nullable String pageToken, int maxResults) {
     return listUsersOp(pageToken, maxResults).callAsync(firebaseApp);
   }
 
   private CallableOperation<ListUsersPage, FirebaseAuthException> listUsersOp(
-      @Nullable String pageToken, int maxResults) {
+      @Nullable final String pageToken, final int maxResults) {
     checkNotDestroyed();
     final PageFactory factory = new PageFactory(
         new DefaultUserSource(userManager, jsonFactory), maxResults, pageToken);
@@ -874,7 +813,7 @@ public class FirebaseAuth {
    *     {@link FirebaseAuthException}.
    * @throws IllegalArgumentException If the user ID string is null or empty.
    */
-  public ApiFuture<Void> deleteUserAsync(final String uid) {
+  public ApiFuture<Void> deleteUserAsync(String uid) {
     return deleteUserOp(uid).callAsync(firebaseApp);
   }
 
@@ -959,7 +898,7 @@ public class FirebaseAuth {
   }
 
   private CallableOperation<UserImportResult, FirebaseAuthException> importUsersOp(
-      List<ImportUserRecord> users, UserImportOptions options) {
+      final List<ImportUserRecord> users, final UserImportOptions options) {
     checkNotDestroyed();
     final UserImportRequest request = new UserImportRequest(users, options, jsonFactory);
     return new CallableOperation<UserImportResult, FirebaseAuthException>() {
@@ -1130,6 +1069,11 @@ public class FirebaseAuth {
             .callAsync(firebaseApp);
   }
 
+  @VisibleForTesting
+  FirebaseUserManager getUserManager() {
+    return this.userManager;
+  }
+
   private CallableOperation<String, FirebaseAuthException> generateEmailActionLinkOp(
           final EmailLinkType type, final String email, final ActionCodeSettings settings) {
     checkNotDestroyed();
@@ -1145,9 +1089,17 @@ public class FirebaseAuth {
     };
   }
 
-  @VisibleForTesting
-  FirebaseUserManager getUserManager() {
-    return this.userManager;
+  private <T> Supplier<T> threadSafeMemoize(final Supplier<T> supplier) {
+    checkNotNull(supplier);
+    return Suppliers.memoize(new Supplier<T>() {
+      @Override
+      public T get() {
+        synchronized (lock) {
+          checkNotDestroyed();
+          return supplier.get();
+        }
+      }
+    });
   }
 
   private void checkNotDestroyed() {
@@ -1163,12 +1115,72 @@ public class FirebaseAuth {
     }
   }
 
-  private static final String SERVICE_ID = FirebaseAuth.class.getName();
+  private static FirebaseAuth fromApp(final FirebaseApp app) {
+    return FirebaseAuth.builder()
+        .setFirebaseApp(app)
+        .setTokenFactory(new Supplier<FirebaseTokenFactory>() {
+          @Override
+          public FirebaseTokenFactory get() {
+            return FirebaseTokenUtils.createTokenFactory(app, Clock.SYSTEM);
+          }
+        })
+        .setIdTokenVerifier(new Supplier<FirebaseTokenVerifier>() {
+          @Override
+          public FirebaseTokenVerifier get() {
+            return FirebaseTokenUtils.createIdTokenVerifier(app, Clock.SYSTEM);
+          }
+        })
+        .setCookieVerifier(new Supplier<FirebaseTokenVerifier>() {
+          @Override
+          public FirebaseTokenVerifier get() {
+            return FirebaseTokenUtils.createSessionCookieVerifier(app, Clock.SYSTEM);
+          }
+        })
+        .build();
+  }
+
+  @VisibleForTesting
+  static Builder builder() {
+    return new Builder();
+  }
+
+  static class Builder {
+    private FirebaseApp firebaseApp;
+    private Supplier<FirebaseTokenFactory> tokenFactory;
+    private Supplier<? extends FirebaseTokenVerifier> idTokenVerifier;
+    private Supplier<? extends FirebaseTokenVerifier> cookieVerifier;
+
+    private Builder() { }
+
+    Builder setFirebaseApp(FirebaseApp firebaseApp) {
+      this.firebaseApp = firebaseApp;
+      return this;
+    }
+
+    Builder setTokenFactory(Supplier<FirebaseTokenFactory> tokenFactory) {
+      this.tokenFactory = tokenFactory;
+      return this;
+    }
+
+    Builder setIdTokenVerifier(Supplier<? extends FirebaseTokenVerifier> idTokenVerifier) {
+      this.idTokenVerifier = idTokenVerifier;
+      return this;
+    }
+
+    Builder setCookieVerifier(Supplier<? extends FirebaseTokenVerifier> cookieVerifier) {
+      this.cookieVerifier = cookieVerifier;
+      return this;
+    }
+
+    FirebaseAuth build() {
+      return new FirebaseAuth(this);
+    }
+  }
 
   private static class FirebaseAuthService extends FirebaseService<FirebaseAuth> {
 
     FirebaseAuthService(FirebaseApp app) {
-      super(SERVICE_ID, new FirebaseAuth(app));
+      super(SERVICE_ID, FirebaseAuth.fromApp(app));
     }
 
     @Override
diff --git a/src/main/java/com/google/firebase/auth/FirebaseToken.java b/src/main/java/com/google/firebase/auth/FirebaseToken.java
index 1726250..3d7b0b2 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseToken.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseToken.java
@@ -16,164 +16,64 @@
 
 package com.google.firebase.auth;
 
-import com.google.api.client.auth.openidconnect.IdToken;
-import com.google.api.client.json.JsonFactory;
-import com.google.api.client.json.webtoken.JsonWebSignature;
-import com.google.api.client.util.Key;
+import static com.google.common.base.Preconditions.checkArgument;
 
-import java.io.IOException;
+import com.google.common.collect.ImmutableMap;
 import java.util.Map;
 
 /**
- * Implementation of a Parsed Firebase Token returned by {@link FirebaseAuth#verifyIdToken(String)}.
- * It can used to get the uid and other attributes of the user provided in the Token.
+ * A decoded and verified Firebase token. Can be used to get the uid and other user attributes
+ * available in the token. See {@link FirebaseAuth#verifyIdToken(String)} and
+ * {@link FirebaseAuth#verifySessionCookie(String)} for details on how to obtain an instance of
+ * this class.
  */
 public final class FirebaseToken {
 
-  private final FirebaseTokenImpl token;
+  private final Map<String, Object> claims;
 
-  FirebaseToken(FirebaseTokenImpl token) {
-    this.token = token;
-  }
-
-  static FirebaseToken parse(JsonFactory jsonFactory, String tokenString) throws IOException {
-    try {
-      JsonWebSignature jws =
-          JsonWebSignature.parser(jsonFactory)
-              .setPayloadClass(FirebaseTokenImpl.Payload.class)
-              .parse(tokenString);
-      return new FirebaseToken(
-          new FirebaseTokenImpl(
-              jws.getHeader(),
-              (FirebaseTokenImpl.Payload) jws.getPayload(),
-              jws.getSignatureBytes(),
-              jws.getSignedContentBytes()));
-    } catch (IOException e) {
-      throw new IOException(
-          "Decoding Firebase ID token failed. Make sure you passed the entire string JWT "
-              + "which represents an ID token. See https://firebase.google.com/docs/auth/admin/"
-              + "verify-id-tokens for details on how to retrieve an ID token.",
-          e);
-    }
+  FirebaseToken(Map<String, Object> claims) {
+    checkArgument(claims != null && claims.containsKey("sub"),
+        "Claims map must at least contain sub");
+    this.claims = ImmutableMap.copyOf(claims);
   }
 
   /** Returns the Uid for the this token. */
   public String getUid() {
-    return token.getPayload().getSubject();
+    return (String) claims.get("sub");
   }
 
   /** Returns the Issuer for the this token. */
   public String getIssuer() {
-    return token.getPayload().getIssuer();
+    return (String) claims.get("iss");
   }
 
   /** Returns the user's display name. */
   public String getName() {
-    return token.getPayload().getName();
+    return (String) claims.get("name");
   }
 
   /** Returns the Uri string of the user's profile photo. */
   public String getPicture() {
-    return token.getPayload().getPicture();
+    return (String) claims.get("picture");
   }
 
   /** 
    * Returns the e-mail address for this user, or {@code null} if it's unavailable.
    */
   public String getEmail() {
-    return token.getPayload().getEmail();
+    return (String) claims.get("email");
   }
 
   /** 
    * Indicates if the email address returned by {@link #getEmail()} has been verified as good.
    */
   public boolean isEmailVerified() {
-    return token.getPayload().isEmailVerified();
+    Object emailVerified = claims.get("email_verified");
+    return emailVerified != null && (Boolean) emailVerified;
   }
 
   /** Returns a map of all of the claims on this token. */
   public Map<String, Object> getClaims() {
-    return token.getPayload();
-  }
-
-  FirebaseTokenImpl getToken() {
-    return token;
-  }
-
-  static class FirebaseTokenImpl extends IdToken {
-
-    FirebaseTokenImpl(
-        Header header, Payload payload, byte[] signatureBytes, byte[] signedContentBytes) {
-      super(header, payload, signatureBytes, signedContentBytes);
-    }
-
-    @Override
-    public Payload getPayload() {
-      return (Payload) super.getPayload();
-    }
-
-    /** Represents a FirebaseWebToken Payload. */
-    public static class Payload extends IdToken.Payload {
-
-      /**
-       * Timestamp of the last time this user authenticated with Firebase on the device receiving
-       * this token.
-       */
-      @Key("auth_time")
-      private long authTime;
-
-      /** User's primary email address. */
-      @Key private String email;
-
-      /** Indicates whether or not the e-mail field is verified to be a known-good address. */
-      @Key("email_verified")
-      private boolean emailVerified;
-
-      /** User's Display Name. */
-      @Key private String name;
-
-      /** URI of the User's profile picture. */
-      @Key private String picture;
-
-      /**
-       * Returns the UID of the user represented by this token. This is an alias for {@link
-       * #getSubject()}
-       */
-      public String getUid() {
-        return getSubject();
-      }
-
-      /**
-       * Returns the time in seconds from the Unix Epoch that this user last authenticated with
-       * Firebase on this device.
-       */
-      public long getAuthTime() {
-        return authTime;
-      }
-
-      /** 
-       * Returns the e-mail address for this user, or {@code null} if it's unavailable.
-       */
-      public String getEmail() {
-        return email;
-      }
-
-      /**
-       * Indicates if the email address returned by {@link #getEmail()} has been verified as good.
-       */
-      public boolean isEmailVerified() {
-        return emailVerified;
-      }
-
-      /** Returns the user's display name. */
-      public String getName() {
-        return name;
-      }
-
-      /** Returns the Uri string of the user's profile photo. */
-      public String getPicture() {
-        return picture;
-      }
-    }
+    return this.claims;
   }
 }
diff --git a/src/main/java/com/google/firebase/auth/FirebaseTokenUtils.java b/src/main/java/com/google/firebase/auth/FirebaseTokenUtils.java
new file mode 100644
index 0000000..dbb5628
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/FirebaseTokenUtils.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright  2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.auth;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.api.client.auth.openidconnect.IdTokenVerifier;
+import com.google.api.client.googleapis.auth.oauth2.GooglePublicKeysManager;
+import com.google.api.client.json.JsonFactory;
+import com.google.api.client.json.gson.GsonFactory;
+import com.google.api.client.util.Clock;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.ImplFirebaseTrampolines;
+import com.google.firebase.auth.internal.CryptoSigners;
+import com.google.firebase.auth.internal.FirebaseTokenFactory;
+
+import java.io.IOException;
+
+final class FirebaseTokenUtils {
+
+  private static final String ID_TOKEN_CERT_URL =
+      "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com";
+  private static final String ID_TOKEN_ISSUER_PREFIX = "https://securetoken.google.com/";
+
+  private static final String SESSION_COOKIE_CERT_URL =
+      "https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys";
+  private static final String SESSION_COOKIE_ISSUER_PREFIX = "https://session.firebase.google.com/";
+
+  // The default JsonFactory implementation we get from Google API client does not support parsing
+  // JSON strings with control characters in text. The public key certificates we get from Google
+  // auth servers contain some control characters, and therefore we must use a JsonFactory that is
+  // capable of parsing such text.
+  static final JsonFactory UNQUOTED_CTRL_CHAR_JSON_FACTORY = new GsonFactory();
+
+  private FirebaseTokenUtils() { }
+
+  static FirebaseTokenFactory createTokenFactory(FirebaseApp firebaseApp, Clock clock) {
+    try {
+      return new FirebaseTokenFactory(
+          firebaseApp.getOptions().getJsonFactory(),
+          clock,
+          CryptoSigners.getCryptoSigner(firebaseApp));
+    } catch (IOException e) {
+      throw new IllegalStateException(
+          "Failed to initialize FirebaseTokenFactory. Make sure to initialize the SDK "
+              + "with service account credentials or specify a service account "
+              + "ID with iam.serviceAccounts.signBlob permission. Please refer to "
+              + "https://firebase.google.com/docs/auth/admin/create-custom-tokens for more "
+              + "details on creating custom tokens.", e);
+    }
+  }
+
+  static FirebaseTokenVerifierImpl createIdTokenVerifier(FirebaseApp app, Clock clock) {
+    String projectId = ImplFirebaseTrampolines.getProjectId(app);
+    checkState(!Strings.isNullOrEmpty(projectId),
+        "Must initialize FirebaseApp with a project ID to call verifyIdToken()");
+    IdTokenVerifier idTokenVerifier = newIdTokenVerifier(
+        clock, ID_TOKEN_ISSUER_PREFIX, projectId);
+    GooglePublicKeysManager publicKeysManager = newPublicKeysManager(
+        app.getOptions(), clock, ID_TOKEN_CERT_URL);
+    return FirebaseTokenVerifierImpl.builder()
+        .setShortName("ID token")
+        .setMethod("verifyIdToken()")
+        .setDocUrl("https://firebase.google.com/docs/auth/admin/verify-id-tokens")
+        .setJsonFactory(app.getOptions().getJsonFactory())
+        .setPublicKeysManager(publicKeysManager)
+        .setIdTokenVerifier(idTokenVerifier)
+        .build();
+  }
+
+  static FirebaseTokenVerifierImpl createSessionCookieVerifier(FirebaseApp app, Clock clock) {
+    String projectId = ImplFirebaseTrampolines.getProjectId(app);
+    checkState(!Strings.isNullOrEmpty(projectId),
+        "Must initialize FirebaseApp with a project ID to call verifySessionCookie()");
+    IdTokenVerifier idTokenVerifier = newIdTokenVerifier(
+        clock, SESSION_COOKIE_ISSUER_PREFIX, projectId);
+    GooglePublicKeysManager publicKeysManager = newPublicKeysManager(
+        app.getOptions(), clock, SESSION_COOKIE_CERT_URL);
+    return FirebaseTokenVerifierImpl.builder()
+        .setJsonFactory(app.getOptions().getJsonFactory())
+        .setPublicKeysManager(publicKeysManager)
+        .setIdTokenVerifier(idTokenVerifier)
+        .setShortName("session cookie")
+        .setMethod("verifySessionCookie()")
+        .setDocUrl("https://firebase.google.com/docs/auth/admin/manage-cookies")
+        .build();
+  }
+
+  private static GooglePublicKeysManager newPublicKeysManager(
+      FirebaseOptions options, Clock clock, String certUrl) {
+    return new GooglePublicKeysManager.Builder(
+        options.getHttpTransport(), UNQUOTED_CTRL_CHAR_JSON_FACTORY)
+        .setClock(clock)
+        .setPublicCertsEncodedUrl(certUrl)
+        .build();
+  }
+
+  private static IdTokenVerifier newIdTokenVerifier(
+      Clock clock, String issuerPrefix, String projectId) {
+    return new IdTokenVerifier.Builder()
+        .setClock(clock)
+        .setAudience(ImmutableList.of(projectId))
+        .setIssuer(issuerPrefix + projectId)
+        .build();
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/FirebaseTokenVerifier.java b/src/main/java/com/google/firebase/auth/FirebaseTokenVerifier.java
new file mode 100644
index 0000000..e73f823
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/FirebaseTokenVerifier.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright  2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.auth;
+
+/**
+ * An interface for verifying Firebase token strings.  Exists mainly to facilitate easy testing
+ * and extension/decoration of the token verification functionality.
+ */
+interface FirebaseTokenVerifier {
+
+  /**
+   * Verifies that the given token string is a valid Firebase JWT.
+   *
+   * @param token The token string to be verified.
+   * @return A decoded representation of the input token string.
+   * @throws FirebaseAuthException If the input token string fails to verify due to any reason.
+   */
+  FirebaseToken verifyToken(String token) throws FirebaseAuthException;
+
+}
diff --git a/src/main/java/com/google/firebase/auth/FirebaseTokenVerifierImpl.java b/src/main/java/com/google/firebase/auth/FirebaseTokenVerifierImpl.java
new file mode 100644
index 0000000..1da2d7a
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/FirebaseTokenVerifierImpl.java
@@ -0,0 +1,328 @@
+/*
+ * Copyright  2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.auth;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.auth.openidconnect.IdToken;
+import com.google.api.client.auth.openidconnect.IdToken.Payload;
+import com.google.api.client.auth.openidconnect.IdTokenVerifier;
+import com.google.api.client.googleapis.auth.oauth2.GooglePublicKeysManager;
+import com.google.api.client.json.JsonFactory;
+import com.google.api.client.json.webtoken.JsonWebSignature.Header;
+import com.google.api.client.util.ArrayMap;
+import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.security.GeneralSecurityException;
+import java.security.PublicKey;
+
+/**
+ * The default implementation of the {@link FirebaseTokenVerifier} interface. Uses the Google API
+ * client's {@code IdToken} API to decode and verify token strings. Can be customized to verify
+ * both Firebase ID tokens and session cookies.
+ */
+final class FirebaseTokenVerifierImpl implements FirebaseTokenVerifier {
+
+  private static final String RS256 = "RS256";
+  private static final String FIREBASE_AUDIENCE =
+      "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit";
+  private static final String ERROR_INVALID_CREDENTIAL = "ERROR_INVALID_CREDENTIAL";
+  private static final String ERROR_RUNTIME_EXCEPTION = "ERROR_RUNTIME_EXCEPTION";
+
+  private final JsonFactory jsonFactory;
+  private final GooglePublicKeysManager publicKeysManager;
+  private final IdTokenVerifier idTokenVerifier;
+  private final String method;
+  private final String shortName;
+  private final String articledShortName;
+  private final String docUrl;
+
+  private FirebaseTokenVerifierImpl(Builder builder) {
+    this.jsonFactory = checkNotNull(builder.jsonFactory);
+    this.publicKeysManager = checkNotNull(builder.publicKeysManager);
+    this.idTokenVerifier = checkNotNull(builder.idTokenVerifier);
+    checkArgument(!Strings.isNullOrEmpty(builder.method), "method name must be specified");
+    checkArgument(!Strings.isNullOrEmpty(builder.shortName), "shortName must be specified");
+    checkArgument(!Strings.isNullOrEmpty(builder.docUrl), "docUrl must be specified");
+    this.method = builder.method;
+    this.shortName = builder.shortName;
+    this.articledShortName = prefixWithIndefiniteArticle(this.shortName);
+    this.docUrl = builder.docUrl;
+  }
+
+  /**
+   * Verifies that the given token string is a valid Firebase JWT. This implementation considers
+   * a token string to be valid if all the following conditions are met:
+   * <ol>
+   *   <li>The token string is a valid RS256 JWT.</li>
+   *   <li>The JWT contains a valid key ID (kid) claim.</li>
+   *   <li>The JWT is not expired, and it has been issued some time in the past.</li>
+   *   <li>The JWT contains valid issuer (iss) and audience (aud) claims as determined by the
+   *   {@code IdTokenVerifier}.</li>
+   *   <li>The JWT contains a valid subject (sub) claim.</li>
+   *   <li>The JWT is signed by a Firebase Auth backend server.</li>
+   * </ol>
+   *
+   * @param token The token string to be verified.
+   * @return A decoded representation of the input token string.
+   * @throws FirebaseAuthException If the input token string does not meet any of the conditions
+   *     listed above.
+   */
+  @Override
+  public FirebaseToken verifyToken(String token) throws FirebaseAuthException {
+    IdToken idToken = parse(token);
+    checkContents(idToken);
+    checkSignature(idToken);
+    return new FirebaseToken(idToken.getPayload());
+  }
+
+  GooglePublicKeysManager getPublicKeysManager() {
+    return publicKeysManager;
+  }
+
+  IdTokenVerifier getIdTokenVerifier() {
+    return idTokenVerifier;
+  }
+
+  String getMethod() {
+    return method;
+  }
+
+  String getShortName() {
+    return shortName;
+  }
+
+  String getArticledShortName() {
+    return articledShortName;
+  }
+
+  String getDocUrl() {
+    return docUrl;
+  }
+
+  private String prefixWithIndefiniteArticle(String word) {
+    if ("aeiouAEIOU".indexOf(word.charAt(0)) < 0) {
+      return "a " + word;
+    } else {
+      return "an " + word;
+    }
+  }
+
+  private IdToken parse(String token) throws FirebaseAuthException {
+    try {
+      return IdToken.parse(jsonFactory, token);
+    } catch (IOException e) {
+      String detailedError = String.format(
+          "Failed to parse Firebase %s. Make sure you passed a string that represents a complete "
+              + "and valid JWT. See %s for details on how to retrieve %s.",
+          shortName,
+          docUrl,
+          articledShortName);
+      throw new FirebaseAuthException(ERROR_INVALID_CREDENTIAL, detailedError, e);
+    }
+  }
+
+  private void checkContents(final IdToken token) throws FirebaseAuthException {
+    String errorMessage = getErrorIfContentInvalid(token);
+    if (errorMessage != null) {
+      String detailedError = String.format("%s %s", errorMessage, getVerifyTokenMessage());
+      throw new FirebaseAuthException(ERROR_INVALID_CREDENTIAL, detailedError);
+    }
+  }
+
+  private void checkSignature(IdToken token) throws FirebaseAuthException {
+    try {
+      if (!isSignatureValid(token)) {
+        throw new FirebaseAuthException(ERROR_INVALID_CREDENTIAL,
+            String.format(
+                "Failed to verify the signature of Firebase %s. %s",
+                shortName,
+                getVerifyTokenMessage()));
+      }
+    } catch (GeneralSecurityException | IOException e) {
+      throw new FirebaseAuthException(
+          ERROR_RUNTIME_EXCEPTION, "Error while verifying signature.", e);
+    }
+  }
+
+  private String getErrorIfContentInvalid(final IdToken idToken) {
+    final Header header = idToken.getHeader();
+    final Payload payload = idToken.getPayload();
+
+    String errorMessage = null;
+    if (header.getKeyId() == null) {
+      errorMessage = getErrorForTokenWithoutKid(header, payload);
+    } else if (!RS256.equals(header.getAlgorithm())) {
+      errorMessage = String.format(
+          "Firebase %s has incorrect algorithm. Expected \"%s\" but got \"%s\".",
+          shortName,
+          RS256,
+          header.getAlgorithm());
+    } else if (!idToken.verifyAudience(idTokenVerifier.getAudience())) {
+      errorMessage = String.format(
+          "Firebase %s has incorrect \"aud\" (audience) claim. Expected \"%s\" but got \"%s\". %s",
+          shortName,
+          joinWithComma(idTokenVerifier.getAudience()),
+          joinWithComma(payload.getAudienceAsList()),
+          getProjectIdMatchMessage());
+    } else if (!idToken.verifyIssuer(idTokenVerifier.getIssuers())) {
+      errorMessage = String.format(
+          "Firebase %s has incorrect \"iss\" (issuer) claim. Expected \"%s\" but got \"%s\". %s",
+          shortName,
+          joinWithComma(idTokenVerifier.getIssuers()),
+          payload.getIssuer(),
+          getProjectIdMatchMessage());
+    } else if (payload.getSubject() == null) {
+      errorMessage = String.format(
+          "Firebase %s has no \"sub\" (subject) claim.",
+          shortName);
+    } else if (payload.getSubject().isEmpty()) {
+      errorMessage = String.format(
+          "Firebase %s has an empty string \"sub\" (subject) claim.",
+          shortName);
+    } else if (payload.getSubject().length() > 128) {
+      errorMessage = String.format(
+          "Firebase %s has \"sub\" (subject) claim longer than 128 characters.",
+          shortName);
+    } else if (!verifyTimestamps(idToken)) {
+      errorMessage = String.format(
+          "Firebase %s has expired or is not yet valid. Get a fresh %s and try again.",
+          shortName,
+          shortName);
+    }
+
+    return errorMessage;
+  }
+
+  private String getVerifyTokenMessage() {
+    return String.format(
+        "See %s for details on how to retrieve %s.",
+        docUrl,
+        articledShortName);
+  }
+
+  /**
+   * Verifies the cryptographic signature on the FirebaseToken. Can block on a web request to fetch
+   * the keys if they have expired.
+   */
+  private boolean isSignatureValid(IdToken token) throws GeneralSecurityException, IOException {
+    for (PublicKey key : publicKeysManager.getPublicKeys()) {
+      if (token.verifySignature(key)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private String getErrorForTokenWithoutKid(IdToken.Header header, IdToken.Payload payload) {
+    if (isCustomToken(payload)) {
+      return String.format("%s expects %s, but was given a custom token.",
+          method, articledShortName);
+    } else if (isLegacyCustomToken(header, payload)) {
+      return String.format("%s expects %s, but was given a legacy custom token.",
+          method, articledShortName);
+    }
+    return String.format("Firebase %s has no \"kid\" claim.", shortName);
+  }
+
+  private String joinWithComma(Iterable<String> strings) {
+    return Joiner.on(',').join(strings);
+  }
+
+  private String getProjectIdMatchMessage() {
+    return String.format(
+        "Make sure the %s comes from the same Firebase project as the service account used to "
+            + "authenticate this SDK.",
+        shortName);
+  }
+
+  private boolean verifyTimestamps(IdToken token) {
+    long currentTimeMillis = idTokenVerifier.getClock().currentTimeMillis();
+    return token.verifyTime(currentTimeMillis, idTokenVerifier.getAcceptableTimeSkewSeconds());
+  }
+
+  private boolean isCustomToken(IdToken.Payload payload) {
+    return FIREBASE_AUDIENCE.equals(payload.getAudience());
+  }
+
+  private boolean isLegacyCustomToken(IdToken.Header header, IdToken.Payload payload) {
+    return "HS256".equals(header.getAlgorithm())
+        && new BigDecimal(0).equals(payload.get("v"))
+        && containsLegacyUidField(payload);
+  }
+
+  private boolean containsLegacyUidField(IdToken.Payload payload) {
+    Object dataField = payload.get("d");
+    if (dataField instanceof ArrayMap) {
+      return ((ArrayMap) dataField).get("uid") != null;
+    }
+    return false;
+  }
+
+  static Builder builder() {
+    return new Builder();
+  }
+
+  static final class Builder {
+
+    private JsonFactory jsonFactory;
+    private GooglePublicKeysManager publicKeysManager;
+    private String method;
+    private String shortName;
+    private IdTokenVerifier idTokenVerifier;
+    private String docUrl;
+
+    private Builder() { }
+
+    Builder setJsonFactory(JsonFactory jsonFactory) {
+      this.jsonFactory = jsonFactory;
+      return this;
+    }
+
+    Builder setPublicKeysManager(GooglePublicKeysManager publicKeysManager) {
+      this.publicKeysManager = publicKeysManager;
+      return this;
+    }
+
+    Builder setMethod(String method) {
+      this.method = method;
+      return this;
+    }
+
+    Builder setShortName(String shortName) {
+      this.shortName = shortName;
+      return this;
+    }
+
+    Builder setIdTokenVerifier(IdTokenVerifier idTokenVerifier) {
+      this.idTokenVerifier = idTokenVerifier;
+      return this;
+    }
+
+    Builder setDocUrl(String docUrl) {
+      this.docUrl = docUrl;
+      return this;
+    }
+
+    FirebaseTokenVerifierImpl build() {
+      return new FirebaseTokenVerifierImpl(this);
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
index 7f534fd..8298499 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
@@ -65,8 +65,6 @@ class FirebaseUserManager {
 
   static final String USER_NOT_FOUND_ERROR = "user-not-found";
   static final String INTERNAL_ERROR = "internal-error";
-  static final String ID_TOKEN_REVOKED_ERROR = "id-token-revoked";
-  static final String SESSION_COOKIE_REVOKED_ERROR = "session-cookie-revoked";
 
   // Map of server-side error codes to SDK error codes.
   // SDK error codes defined at: https://firebase.google.com/docs/auth/admin/errors
diff --git a/src/main/java/com/google/firebase/auth/RevocationCheckDecorator.java b/src/main/java/com/google/firebase/auth/RevocationCheckDecorator.java
new file mode 100644
index 0000000..e53ad25
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/RevocationCheckDecorator.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright  2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.auth;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.base.Strings;
+
+/**
+ * A decorator for adding token revocation checks to an existing {@link FirebaseTokenVerifier}.
+ */
+class RevocationCheckDecorator implements FirebaseTokenVerifier {
+
+  static final String ID_TOKEN_REVOKED_ERROR = "id-token-revoked";
+  static final String SESSION_COOKIE_REVOKED_ERROR = "session-cookie-revoked";
+
+  private final FirebaseTokenVerifier tokenVerifier;
+  private final FirebaseUserManager userManager;
+  private final String errorCode;
+  private final String shortName;
+
+  private RevocationCheckDecorator(
+      FirebaseTokenVerifier tokenVerifier,
+      FirebaseUserManager userManager,
+      String errorCode,
+      String shortName) {
+    this.tokenVerifier = checkNotNull(tokenVerifier);
+    this.userManager = checkNotNull(userManager);
+    checkArgument(!Strings.isNullOrEmpty(errorCode));
+    checkArgument(!Strings.isNullOrEmpty(shortName));
+    this.errorCode = errorCode;
+    this.shortName = shortName;
+  }
+
+  /**
+   * If the wrapped {@link FirebaseTokenVerifier} deems the input token string is valid, checks
+   * whether the token has been revoked.
+   */
+  @Override
+  public FirebaseToken verifyToken(String token) throws FirebaseAuthException {
+    FirebaseToken firebaseToken = tokenVerifier.verifyToken(token);
+    if (isRevoked(firebaseToken)) {
+      throw new FirebaseAuthException(errorCode, "Firebase " + shortName + " revoked");
+    }
+    return firebaseToken;
+  }
+
+  private boolean isRevoked(FirebaseToken firebaseToken) throws FirebaseAuthException {
+    UserRecord user = userManager.getUserById(firebaseToken.getUid());
+    long issuedAtInSeconds = (long) firebaseToken.getClaims().get("iat");
+    return user.getTokensValidAfterTimestamp() > issuedAtInSeconds * 1000;
+  }
+
+  static RevocationCheckDecorator decorateIdTokenVerifier(
+      FirebaseTokenVerifier tokenVerifier, FirebaseUserManager userManager) {
+    return new RevocationCheckDecorator(
+        tokenVerifier, userManager, ID_TOKEN_REVOKED_ERROR, "id token");
+  }
+
+  static RevocationCheckDecorator decorateSessionCookieVerifier(
+      FirebaseTokenVerifier tokenVerifier, FirebaseUserManager userManager) {
+    return new RevocationCheckDecorator(
+        tokenVerifier, userManager, SESSION_COOKIE_REVOKED_ERROR, "session cookie");
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java b/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java
index 3447f05..6ea70b8 100644
--- a/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java
+++ b/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java
@@ -31,7 +31,7 @@ import java.util.Map;
 /**
  * A set of {@link CryptoSigner} implementations and utilities for interacting with them.
  */
-class CryptoSigners {
+public class CryptoSigners {
 
   private static final String METADATA_SERVICE_URL =
       "http://metadata/computeMetadata/v1/instance/service-accounts/default/email";
@@ -128,7 +128,7 @@ class CryptoSigners {
    * Initializes a {@link CryptoSigner} instance for the given Firebase app. Follows the protocol
    * documented at go/firebase-admin-sign.
    */
-  static CryptoSigner getCryptoSigner(FirebaseApp firebaseApp) throws IOException {
+  public static CryptoSigner getCryptoSigner(FirebaseApp firebaseApp) throws IOException {
     GoogleCredentials credentials = ImplFirebaseTrampolines.getCredentials(firebaseApp);
 
     // If the SDK was initialized with a service account, use it to sign bytes.
diff --git a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java
index 72ef917..95d3131 100644
--- a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java
+++ b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java
@@ -27,7 +27,6 @@ import com.google.api.client.util.Clock;
 import com.google.api.client.util.StringUtils;
 
 import com.google.common.base.Strings;
-import com.google.firebase.FirebaseApp;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Map;
@@ -43,7 +42,7 @@ public class FirebaseTokenFactory {
   private final Clock clock;
   private final CryptoSigner signer;
 
-  FirebaseTokenFactory(JsonFactory jsonFactory, Clock clock, CryptoSigner signer) {
+  public FirebaseTokenFactory(JsonFactory jsonFactory, Clock clock, CryptoSigner signer) {
     this.jsonFactory = checkNotNull(jsonFactory);
     this.clock = checkNotNull(clock);
     this.signer = checkNotNull(signer);
@@ -94,12 +93,4 @@ public class FirebaseTokenFactory {
     String signature = Base64.encodeBase64URLSafeString(signer.sign(contentBytes));
     return content + "." + signature;
   }
-
-  public static FirebaseTokenFactory fromApp(
-      FirebaseApp firebaseApp, Clock clock) throws IOException {
-    return new FirebaseTokenFactory(
-        firebaseApp.getOptions().getJsonFactory(),
-        clock,
-        CryptoSigners.getCryptoSigner(firebaseApp));
-  }
 }
diff --git a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenVerifier.java b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenVerifier.java
deleted file mode 100644
index 98b269f..0000000
--- a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenVerifier.java
+++ /dev/null
@@ -1,335 +0,0 @@
-/*
- * Copyright 2017 Google Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.firebase.auth.internal;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.api.client.auth.openidconnect.IdToken;
-import com.google.api.client.auth.openidconnect.IdToken.Payload;
-import com.google.api.client.auth.openidconnect.IdTokenVerifier;
-import com.google.api.client.googleapis.auth.oauth2.GooglePublicKeysManager;
-import com.google.api.client.json.webtoken.JsonWebSignature.Header;
-import com.google.api.client.util.ArrayMap;
-import com.google.api.client.util.Clock;
-import com.google.common.base.Strings;
-import com.google.firebase.auth.FirebaseAuthException;
-
-import com.google.firebase.internal.NonNull;
-import java.io.IOException;
-import java.math.BigDecimal;
-import java.security.GeneralSecurityException;
-import java.security.PublicKey;
-import java.util.Collection;
-import java.util.Collections;
-
-/**
- * Verifies that a JWT returned by Firebase is valid for use in the this project.
- *
- * <p>This class should be kept as a Singleton within the server in order to maximize caching of the
- * public signing keys.
- */
-public final class FirebaseTokenVerifier extends IdTokenVerifier {
-
-  static final String ID_TOKEN_CERT_URL =
-      "https://www.googleapis.com/robot/v1/metadata/x509/"
-          + "securetoken@system.gserviceaccount.com";
-  static final String ID_TOKEN_ISSUER_PREFIX = "https://securetoken.google.com/";
-
-  static final String SESSION_COOKIE_CERT_URL =
-      "https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys";
-  static final String SESSION_COOKIE_ISSUER_PREFIX = "https://session.firebase.google.com/";
-
-  private static final String FIREBASE_AUDIENCE =
-      "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit";
-
-  private static final String ERROR_INVALID_CREDENTIAL = "ERROR_INVALID_CREDENTIAL";
-  private static final String ERROR_RUNTIME_EXCEPTION = "ERROR_RUNTIME_EXCEPTION";
-  private static final String PROJECT_ID_MATCH_MESSAGE =
-      "Make sure the %s comes from the same Firebase project as the service account used to "
-          + "authenticate this SDK.";
-  private static final String VERIFY_TOKEN_DOCS_MESSAGE =
-      "See %s for details on how to retrieve %s.";
-  private static final String ALGORITHM = "RS256";
-
-  private final String projectId;
-  private final GooglePublicKeysManager publicKeysManager;
-  private final String method;
-  private final String shortName;
-  private final String articledShortName;
-
-  private final String projectIdMatchMessage;
-  private final String verifyTokenMessage;
-
-  private FirebaseTokenVerifier(Builder builder) {
-    super(builder);
-    checkArgument(!Strings.isNullOrEmpty(builder.projectId), "projectId must be set");
-    checkArgument(!Strings.isNullOrEmpty(builder.shortName), "shortName must be set");
-    checkArgument(!Strings.isNullOrEmpty(builder.method), "method must be set");
-    this.projectId = builder.projectId;
-    this.shortName = builder.shortName;
-    this.articledShortName = prefixWithIndefiniteArticle(shortName);
-    this.method = builder.method;
-    this.publicKeysManager = checkNotNull(builder.publicKeysManager,
-        "publicKeysManager must be set");
-    this.projectIdMatchMessage = String.format(PROJECT_ID_MATCH_MESSAGE, shortName);
-    this.verifyTokenMessage = String.format(VERIFY_TOKEN_DOCS_MESSAGE, builder.docUrl,
-        articledShortName);
-  }
-
-  /**
-   * We are changing the semantics of the super-class method in order to provide more details on why
-   * this is failing to the developer.
-   */
-  public void verifyTokenAndSignature(IdToken token) throws FirebaseAuthException {
-    Payload payload = token.getPayload();
-    Header header = token.getHeader();
-    String errorMessage = null;
-
-    boolean isCustomToken =
-        payload.getAudience() != null && payload.getAudience().equals(FIREBASE_AUDIENCE);
-    boolean isLegacyCustomToken =
-        header.getAlgorithm() != null
-            && header.getAlgorithm().equals("HS256")
-            && payload.get("v") != null
-            && payload.get("v").equals(new BigDecimal(0))
-            && payload.get("d") != null
-            && payload.get("d") instanceof ArrayMap
-            && ((ArrayMap) payload.get("d")).get("uid") != null;
-
-    if (header.getKeyId() == null) {
-      if (isCustomToken) {
-        errorMessage = String.format("%s expects %s, but was given a custom token.",
-            method, articledShortName);
-      } else if (isLegacyCustomToken) {
-        errorMessage = String.format("%s expects %s, but was given a legacy custom token.",
-            method, articledShortName);
-      } else {
-        errorMessage = String.format("Firebase %s has no \"kid\" claim.", shortName);
-      }
-    } else if (header.getAlgorithm() == null || !header.getAlgorithm().equals(ALGORITHM)) {
-      errorMessage =
-          String.format(
-              "Firebase %s has incorrect algorithm. Expected \"%s\" but got \"%s\".",
-              shortName, ALGORITHM, header.getAlgorithm());
-    } else if (!token.verifyAudience(getAudience())) {
-      errorMessage =
-          String.format(
-              "Firebase %s has incorrect \"aud\" (audience) claim. Expected \"%s\" but got "
-                  + "\"%s\". %s", shortName, concat(getAudience()),
-              concat(token.getPayload().getAudienceAsList()), projectIdMatchMessage);
-    } else if (!token.verifyIssuer(getIssuers())) {
-      errorMessage =
-          String.format(
-              "Firebase %s has incorrect \"iss\" (issuer) claim. "
-                  + "Expected \"%s\" but got \"%s\". %s", shortName, concat(getIssuers()),
-              token.getPayload().getIssuer(), projectIdMatchMessage);
-    } else if (payload.getSubject() == null) {
-      errorMessage = String.format("Firebase %s has no \"sub\" (subject) claim.", shortName);
-    } else if (payload.getSubject().isEmpty()) {
-      errorMessage = String.format("Firebase %s has an empty string \"sub\" (subject) claim.",
-          shortName);
-    } else if (payload.getSubject().length() > 128) {
-      errorMessage = String.format("Firebase %s has \"sub\" (subject) claim longer than "
-          + "128 characters.", shortName);
-    } else if (!token.verifyTime(getClock().currentTimeMillis(), getAcceptableTimeSkewSeconds())) {
-      errorMessage =
-          String.format("Firebase %s has expired or is not yet valid. Get a fresh %s and "
-              + "try again.", shortName, shortName);
-    }
-
-    if (errorMessage != null) {
-      throw new FirebaseAuthException(ERROR_INVALID_CREDENTIAL,
-          String.format("%s %s", errorMessage, verifyTokenMessage));
-    }
-
-    try {
-      if (!verifySignature(token)) {
-        throw new FirebaseAuthException(ERROR_INVALID_CREDENTIAL,
-            String.format("Firebase %s isn't signed by a valid public key. %s",
-                shortName, verifyTokenMessage));
-      }
-    } catch (IOException | GeneralSecurityException e) {
-      throw new FirebaseAuthException(
-          ERROR_RUNTIME_EXCEPTION, "Error while verifying signature.", e);
-    }
-  }
-
-  private String prefixWithIndefiniteArticle(String word) {
-    if ("aeiouAEIOU".indexOf(word.charAt(0)) < 0) {
-      return "a " + word;
-    } else {
-      return "an " + word;
-    }
-  }
-
-  private String concat(Collection<String> collection) {
-    StringBuilder stringBuilder = new StringBuilder();
-    for (String inputLine : collection) {
-      stringBuilder.append(inputLine.trim()).append(", ");
-    }
-    return stringBuilder.substring(0, stringBuilder.length() - 2);
-  }
-
-  /**
-   * Verifies the cryptographic signature on the FirebaseToken. Can block on a web request to fetch
-   * the keys if they have expired.
-   */
-  private boolean verifySignature(IdToken token) throws GeneralSecurityException, IOException {
-    for (PublicKey key : publicKeysManager.getPublicKeys()) {
-      if (token.verifySignature(key)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  public String getProjectId() {
-    return projectId;
-  }
-
-  /** 
-   * Builder for {@link FirebaseTokenVerifier}.
-   */
-  public static class Builder extends IdTokenVerifier.Builder {
-
-    private String projectId;
-    private String shortName;
-    private String method;
-    private String docUrl;
-    private GooglePublicKeysManager publicKeysManager;
-
-    public String getProjectId() {
-      return projectId;
-    }
-
-    /**
-     * Sets the project ID and a URL prefix for the issuer (iss) claim. The full issuer claim
-     * is the concatenation of the prefix and the project ID.
-     *
-     * @param issuerPrefix A URL prefix.
-     * @param projectId A Firebase project ID.
-     * @return This builder.
-     */
-    public Builder setProjectId(String issuerPrefix, String projectId) {
-      this.projectId = projectId;
-      this.setAudience(Collections.singleton(projectId));
-      this.setIssuer(issuerPrefix + projectId);
-      return this;
-    }
-
-    /**
-     * Sets the short name of the type of tokens being validated (e.g. ID token, session cookie).
-     *
-     * @param shortName A short string identifier.
-     * @return This builder.
-     */
-    public Builder setShortName(String shortName) {
-      this.shortName = shortName;
-      return this;
-    }
-
-    /**
-     * Sets the name of the operation that triggers token verification (e.g. verifyIdToken())
-     *
-     * @param method A method name.
-     * @return This builder.
-     */
-    public Builder setMethod(String method) {
-      this.method = method;
-      return this;
-    }
-
-    /**
-     * A URL to public documentation where more information about token verification can be found.
-     *
-     * @param docUrl A documentation URL.
-     * @return This builder.
-     */
-    public Builder setDocUrl(String docUrl) {
-      this.docUrl = docUrl;
-      return this;
-    }
-
-    /**
-     * Sets the {@code Clock} instance to be used to compare token issue and expiry times.
-     *
-     * @param clock A {@code Clock} instance.
-     * @return This builder.
-     */
-    @Override
-    public Builder setClock(Clock clock) {
-      return (Builder) super.setClock(clock);
-    }
-
-    /**
-     * Overrides the GooglePublicKeysManager from the default.
-     *
-     * @param publicKeysManager A public keys manager.
-     * @return This builder.
-     */
-    public Builder setPublicKeysManager(GooglePublicKeysManager publicKeysManager) {
-      this.publicKeysManager = publicKeysManager;
-      return this;
-    }
-
-    @Override
-    public FirebaseTokenVerifier build() {
-      return new FirebaseTokenVerifier(this);
-    }
-  }
-
-  /**
-   * Creates a new {@link FirebaseTokenVerifier} for verifying Firebase ID tokens.
-   *
-   * @param projectId Project ID string
-   * @param keyManagers {@link KeyManagers} instance with public key managers to use
-   * @param clock {@code Clock} instance for Google API client
-   * @return A new {@link FirebaseTokenVerifier} instance
-   */
-  @NonNull public static FirebaseTokenVerifier createIdTokenVerifier(
-      @NonNull String projectId, @NonNull KeyManagers keyManagers, @NonNull Clock clock) {
-    return new FirebaseTokenVerifier.Builder()
-        .setProjectId(ID_TOKEN_ISSUER_PREFIX, projectId)
-        .setPublicKeysManager(keyManagers.getIdTokenKeysManager())
-        .setShortName("ID token")
-        .setMethod("verifyIdToken()")
-        .setDocUrl("https://firebase.google.com/docs/auth/admin/verify-id-tokens")
-        .setClock(clock)
-        .build();
-  }
-
-  /**
-   * Creates a new {@link FirebaseTokenVerifier} for verifying Firebase ID tokens.
-   *
-   * @param projectId Project ID string
-   * @param keyManagers {@link KeyManagers} instance with public key managers to use
-   * @param clock {@code Clock} instance for Google API client
-   * @return A new {@link FirebaseTokenVerifier} instance
-   */
-  @NonNull public static FirebaseTokenVerifier createSessionCookieVerifier(
-      @NonNull String projectId, @NonNull KeyManagers keyManagers, @NonNull Clock clock) {
-    return new FirebaseTokenVerifier.Builder()
-        .setProjectId(SESSION_COOKIE_ISSUER_PREFIX, projectId)
-        .setPublicKeysManager(keyManagers.getSessionCookieKeysManager())
-        .setShortName("session cookie")
-        .setMethod("verifySessionCookie()")
-        .setDocUrl("https://firebase.google.com/docs/auth/admin/manage-cookies")
-        .setClock(clock)
-        .build();
-  }
-}
diff --git a/src/main/java/com/google/firebase/auth/internal/KeyManagers.java b/src/main/java/com/google/firebase/auth/internal/KeyManagers.java
deleted file mode 100644
index b22837a..0000000
--- a/src/main/java/com/google/firebase/auth/internal/KeyManagers.java
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- * Copyright 2017 Google Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.firebase.auth.internal;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.api.client.googleapis.auth.oauth2.GooglePublicKeysManager;
-import com.google.api.client.http.HttpTransport;
-import com.google.api.client.json.gson.GsonFactory;
-import com.google.api.client.util.Clock;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.firebase.FirebaseApp;
-import com.google.firebase.internal.NonNull;
-
-/**
- * A utility for initializing and keeping tack of the various public key manager instances
- * used by {@link com.google.firebase.auth.FirebaseAuth}.
- */
-public class KeyManagers {
-
-  private final GooglePublicKeysManager idTokenKeysManager;
-  private final GooglePublicKeysManager sessionCookieKeysManager;
-
-  private KeyManagers(
-      GooglePublicKeysManager idTokenKeysManager,
-      GooglePublicKeysManager sessionCookieKeysManager) {
-    this.idTokenKeysManager = checkNotNull(idTokenKeysManager);
-    this.sessionCookieKeysManager = checkNotNull(sessionCookieKeysManager);
-  }
-
-  /**
-   * Returns the key manager that should be used for ID token verification.
-   */
-  GooglePublicKeysManager getIdTokenKeysManager() {
-    return idTokenKeysManager;
-  }
-
-  /**
-   * Returns the key manager that should be used for session cookie verification.
-   */
-  GooglePublicKeysManager getSessionCookieKeysManager() {
-    return sessionCookieKeysManager;
-  }
-
-  /**
-   * Initialize a new set of key managers for the specified app using the given clock.
-   *
-   * @param app A {@link FirebaseApp} instance.
-   * @param clock A {@code Clock} to be used with Google API client.
-   * @return A new {@link KeyManagers} instance.
-   */
-  public static KeyManagers getDefault(@NonNull FirebaseApp app, @NonNull Clock clock) {
-    HttpTransport transport = app.getOptions().getHttpTransport();
-    return getDefault(transport, clock);
-  }
-
-  @VisibleForTesting
-  static KeyManagers getDefault(HttpTransport transport, Clock clock) {
-    return new KeyManagers(
-        createPublicKeysManager(transport, clock, FirebaseTokenVerifier.ID_TOKEN_CERT_URL),
-        createPublicKeysManager(transport, clock, FirebaseTokenVerifier.SESSION_COOKIE_CERT_URL));
-  }
-
-  private static GooglePublicKeysManager createPublicKeysManager(
-      HttpTransport transport, Clock clock, String certUrl) {
-    return new GooglePublicKeysManager.Builder(transport, new GsonFactory())
-        .setClock(clock)
-        .setPublicCertsEncodedUrl(certUrl)
-        .build();
-  }
-}
diff --git a/src/main/java/com/google/firebase/internal/DateUtils.java b/src/main/java/com/google/firebase/internal/DateUtils.java
new file mode 100644
index 0000000..6c4eeb7
--- /dev/null
+++ b/src/main/java/com/google/firebase/internal/DateUtils.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.internal;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.text.ParsePosition;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.TimeZone;
+
+/**
+ * A utility class for parsing and formatting HTTP dates as used in cookies and
+ * other headers.  This class handles dates as defined by RFC 2616 section
+ * 3.3.1 as well as some other common non-standard formats.
+ *
+ * <p>Most of this class was borrowed from the
+ * <a href="http://svn.apache.org/repos/asf/httpcomponents/httpclient/tags/4.3/httpclient/src/main/java/org/apache/http/client/utils/DateUtils.java">
+ * Apache HTTP client</a> in order to avoid a direct dependency on it. We currently
+ * have a transitive dependency on this library (via Google API client), but the API
+ * client team is working towards removing it, so we won't have it in the classpath for long.
+ *
+ * <p>The original implementation of this class uses
+ * thread locals to cache the {@code SimpleDateFormat} instances. Instead, this implementation
+ * uses static constants and explicit locking to ensure thread safety. This is probably slower,
+ * but also simpler and avoids memory leaks that may result from unreleased thread locals.
+ */
+final class DateUtils {
+
+  /**
+   * Date format pattern used to parse HTTP date headers in RFC 1123 format.
+   */
+  static final String PATTERN_RFC1123 = "EEE, dd MMM yyyy HH:mm:ss zzz";
+
+  /**
+   * Date format pattern used to parse HTTP date headers in RFC 1036 format.
+   */
+  static final String PATTERN_RFC1036 = "EEE, dd-MMM-yy HH:mm:ss zzz";
+
+  /**
+   * Date format pattern used to parse HTTP date headers in ANSI C
+   * {@code asctime()} format.
+   */
+  static final String PATTERN_ASCTIME = "EEE MMM d HH:mm:ss yyyy";
+
+  private static final SimpleDateFormat[] DEFAULT_PATTERNS = new SimpleDateFormat[] {
+      new SimpleDateFormat(PATTERN_RFC1123),
+      new SimpleDateFormat(PATTERN_RFC1036),
+      new SimpleDateFormat(PATTERN_ASCTIME)
+  };
+
+  static final TimeZone GMT = TimeZone.getTimeZone("GMT");
+
+  static {
+    final Calendar calendar = Calendar.getInstance();
+    calendar.setTimeZone(GMT);
+    calendar.set(2000, Calendar.JANUARY, 1, 0, 0, 0);
+    calendar.set(Calendar.MILLISECOND, 0);
+    final Date defaultTwoDigitYearStart = calendar.getTime();
+
+    for (final SimpleDateFormat datePattern : DEFAULT_PATTERNS) {
+      datePattern.set2DigitYearStart(defaultTwoDigitYearStart);
+    }
+  }
+
+  /**
+   * Parses the date value using the given date formats.
+   *
+   * @param dateValue the date value to parse
+   * @return the parsed date or null if input could not be parsed
+   */
+  public static Date parseDate(final String dateValue) {
+    String v = checkNotNull(dateValue);
+    // trim single quotes around date if present
+    // see issue #5279
+    if (v.length() > 1 && v.startsWith("'") && v.endsWith("'")) {
+      v = v.substring(1, v.length() - 1);
+    }
+
+    for (final SimpleDateFormat datePattern : DEFAULT_PATTERNS) {
+      final ParsePosition pos = new ParsePosition(0);
+      synchronized (datePattern) {
+        final Date result = datePattern.parse(v, pos);
+        if (pos.getIndex() != 0) {
+          return result;
+        }
+      }
+    }
+    return null;
+  }
+
+  /** This class should not be instantiated. */
+  private DateUtils() {
+  }
+}
diff --git a/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java b/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java
index 0cd4e93..e73f939 100644
--- a/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java
+++ b/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java
@@ -19,32 +19,57 @@ package com.google.firebase.internal;
 import com.google.api.client.http.HttpRequest;
 import com.google.api.client.http.HttpRequestInitializer;
 import com.google.auth.http.HttpCredentialsAdapter;
-import com.google.auth.oauth2.GoogleCredentials;
+import com.google.common.collect.ImmutableList;
 import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
 import com.google.firebase.ImplFirebaseTrampolines;
 import java.io.IOException;
+import java.util.List;
 
 /**
- * {@code HttpRequestInitializer} for configuring outgoing REST calls. Handles OAuth2 authorization
- * and setting timeout values.
+ * {@code HttpRequestInitializer} for configuring outgoing REST calls. Initializes requests with
+ * OAuth2 credentials, timeout and retry settings.
  */
-public class FirebaseRequestInitializer implements HttpRequestInitializer {
+public final class FirebaseRequestInitializer implements HttpRequestInitializer {
 
-  private final HttpCredentialsAdapter credentialsAdapter;
-  private final int connectTimeout;
-  private final int readTimeout;
+  private final List<HttpRequestInitializer> initializers;
 
   public FirebaseRequestInitializer(FirebaseApp app) {
-    GoogleCredentials credentials = ImplFirebaseTrampolines.getCredentials(app);
-    this.credentialsAdapter = new HttpCredentialsAdapter(credentials);
-    this.connectTimeout = app.getOptions().getConnectTimeout();
-    this.readTimeout = app.getOptions().getReadTimeout();
+    this(app, null);
+  }
+
+  public FirebaseRequestInitializer(FirebaseApp app, @Nullable RetryConfig retryConfig) {
+    ImmutableList.Builder<HttpRequestInitializer> initializers =
+        ImmutableList.<HttpRequestInitializer>builder()
+            .add(new HttpCredentialsAdapter(ImplFirebaseTrampolines.getCredentials(app)))
+            .add(new TimeoutInitializer(app.getOptions()));
+    if (retryConfig != null) {
+      initializers.add(new RetryInitializer(retryConfig));
+    }
+    this.initializers = initializers.build();
   }
 
   @Override
-  public void initialize(HttpRequest httpRequest) throws IOException {
-    credentialsAdapter.initialize(httpRequest);
-    httpRequest.setConnectTimeout(connectTimeout);
-    httpRequest.setReadTimeout(readTimeout);
+  public void initialize(HttpRequest request) throws IOException {
+    for (HttpRequestInitializer initializer : initializers) {
+      initializer.initialize(request);
+    }
+  }
+
+  private static class TimeoutInitializer implements HttpRequestInitializer {
+
+    private final int connectTimeoutMillis;
+    private final int readTimeoutMillis;
+
+    TimeoutInitializer(FirebaseOptions options) {
+      this.connectTimeoutMillis = options.getConnectTimeout();
+      this.readTimeoutMillis = options.getReadTimeout();
+    }
+
+    @Override
+    public void initialize(HttpRequest request) {
+      request.setConnectTimeout(connectTimeoutMillis);
+      request.setReadTimeout(readTimeoutMillis);
+    }
   }
 }
diff --git a/src/main/java/com/google/firebase/internal/RetryConfig.java b/src/main/java/com/google/firebase/internal/RetryConfig.java
new file mode 100644
index 0000000..a17780e
--- /dev/null
+++ b/src/main/java/com/google/firebase/internal/RetryConfig.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.internal;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.util.BackOff;
+import com.google.api.client.util.ExponentialBackOff;
+import com.google.api.client.util.Sleeper;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Configures when and how HTTP requests should be retried.
+ */
+public final class RetryConfig {
+
+  private static final int INITIAL_INTERVAL_MILLIS = 500;
+
+  private final List<Integer> retryStatusCodes;
+  private final boolean retryOnIOExceptions;
+  private final int maxRetries;
+  private final Sleeper sleeper;
+  private final ExponentialBackOff.Builder backOffBuilder;
+
+  private RetryConfig(Builder builder) {
+    if (builder.retryStatusCodes != null) {
+      this.retryStatusCodes = ImmutableList.copyOf(builder.retryStatusCodes);
+    } else {
+      this.retryStatusCodes = ImmutableList.of();
+    }
+
+    this.retryOnIOExceptions = builder.retryOnIOExceptions;
+    checkArgument(builder.maxRetries >= 0, "maxRetries must not be negative");
+    this.maxRetries = builder.maxRetries;
+    this.sleeper = checkNotNull(builder.sleeper);
+    this.backOffBuilder = new ExponentialBackOff.Builder()
+        .setInitialIntervalMillis(INITIAL_INTERVAL_MILLIS)
+        .setMaxIntervalMillis(builder.maxIntervalMillis)
+        .setMultiplier(builder.backOffMultiplier)
+        .setRandomizationFactor(0);
+
+    // Force validation of arguments by building the BackOff object
+    this.backOffBuilder.build();
+  }
+
+  List<Integer> getRetryStatusCodes() {
+    return retryStatusCodes;
+  }
+
+  boolean isRetryOnIOExceptions() {
+    return retryOnIOExceptions;
+  }
+
+  int getMaxRetries() {
+    return maxRetries;
+  }
+
+  int getMaxIntervalMillis() {
+    return backOffBuilder.getMaxIntervalMillis();
+  }
+
+  double getBackOffMultiplier() {
+    return backOffBuilder.getMultiplier();
+  }
+
+  Sleeper getSleeper() {
+    return sleeper;
+  }
+
+  BackOff newBackOff() {
+    return backOffBuilder.build();
+  }
+
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static final class Builder {
+
+    private List<Integer> retryStatusCodes;
+    private boolean retryOnIOExceptions;
+    private int maxRetries;
+    private int maxIntervalMillis = (int) TimeUnit.MINUTES.toMillis(2);
+    private double backOffMultiplier = 2.0;
+    private Sleeper sleeper = Sleeper.DEFAULT;
+
+    private Builder() { }
+
+    /**
+     * Sets a list of HTTP status codes that should be retried. If null or empty, HTTP requests
+     * will not be retried as long as they result in some HTTP response message.
+     *
+     * @param retryStatusCodes A list of status codes.
+     * @return This builder.
+     */
+    public Builder setRetryStatusCodes(List<Integer> retryStatusCodes) {
+      this.retryStatusCodes = retryStatusCodes;
+      return this;
+    }
+
+    /**
+     * Sets whether requests should be retried on IOExceptions.
+     *
+     * @param retryOnIOExceptions A boolean indicating whether to retry on IOExceptions.
+     * @return This builder.
+     */
+    public Builder setRetryOnIOExceptions(boolean retryOnIOExceptions) {
+      this.retryOnIOExceptions = retryOnIOExceptions;
+      return this;
+    }
+
+    /**
+     * Maximum number of retry attempts for a request. This is the cumulative total for all retries
+     * regardless of their cause (I/O errors and HTTP error responses).
+     *
+     * @param maxRetries A non-negative integer.
+     * @return This builder.
+     */
+    public Builder setMaxRetries(int maxRetries) {
+      this.maxRetries = maxRetries;
+      return this;
+    }
+
+    /**
+     * Maximum interval to wait before a request should be retried. Must be at least 500
+     * milliseconds. Defaults to 2 minutes.
+     *
+     * @param maxIntervalMillis Interval in milliseconds.
+     * @return This builder.
+     */
+    public Builder setMaxIntervalMillis(int maxIntervalMillis) {
+      this.maxIntervalMillis = maxIntervalMillis;
+      return this;
+    }
+
+    /**
+     * Factor by which the retry interval is multiplied when employing exponential back
+     * off to delay consecutive retries of the same request. Must be at least 1. Defaults
+     * to 2.
+     *
+     * @param backOffMultiplier Multiplication factor for exponential back off.
+     * @return This builder.
+     */
+    public Builder setBackOffMultiplier(double backOffMultiplier) {
+      this.backOffMultiplier = backOffMultiplier;
+      return this;
+    }
+
+    @VisibleForTesting
+    Builder setSleeper(Sleeper sleeper) {
+      this.sleeper = sleeper;
+      return this;
+    }
+
+    public RetryConfig build() {
+      return new RetryConfig(this);
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/internal/RetryInitializer.java b/src/main/java/com/google/firebase/internal/RetryInitializer.java
new file mode 100644
index 0000000..fbe0a13
--- /dev/null
+++ b/src/main/java/com/google/firebase/internal/RetryInitializer.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.internal;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.http.HttpBackOffIOExceptionHandler;
+import com.google.api.client.http.HttpIOExceptionHandler;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestInitializer;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpUnsuccessfulResponseHandler;
+import java.io.IOException;
+
+/**
+ * Configures HTTP requests to be retried. Requests that encounter I/O errors are retried if
+ * {@link RetryConfig#isRetryOnIOExceptions()} is set. Requests failing with unsuccessful HTTP
+ * responses are first referred to the {@code HttpUnsuccessfulResponseHandler} that was originally
+ * set on the request. If the request does not get retried at that level,
+ * {@link RetryUnsuccessfulResponseHandler} is used to schedule additional retries.
+ */
+final class RetryInitializer implements HttpRequestInitializer {
+
+  private final RetryConfig retryConfig;
+
+  RetryInitializer(RetryConfig retryConfig) {
+    this.retryConfig = checkNotNull(retryConfig);
+  }
+
+  @Override
+  public void initialize(HttpRequest request) {
+    request.setNumberOfRetries(retryConfig.getMaxRetries());
+    request.setUnsuccessfulResponseHandler(newUnsuccessfulResponseHandler(request));
+    if (retryConfig.isRetryOnIOExceptions()) {
+      request.setIOExceptionHandler(newIOExceptionHandler());
+    }
+  }
+
+  private HttpUnsuccessfulResponseHandler newUnsuccessfulResponseHandler(HttpRequest request) {
+    RetryUnsuccessfulResponseHandler retryHandler = new RetryUnsuccessfulResponseHandler(
+        retryConfig);
+    return new RetryHandlerDecorator(retryHandler, request);
+  }
+
+  private HttpIOExceptionHandler newIOExceptionHandler() {
+    return new HttpBackOffIOExceptionHandler(retryConfig.newBackOff())
+        .setSleeper(retryConfig.getSleeper());
+  }
+
+  /**
+   * Makes sure that any error handlers already set on the request are executed before the retry
+   * handler is called. This is needed since some initializers (e.g. HttpCredentialsAdapter)
+   * register their own error handlers.
+   */
+  static class RetryHandlerDecorator implements HttpUnsuccessfulResponseHandler {
+
+    private final RetryUnsuccessfulResponseHandler retryHandler;
+    private final HttpUnsuccessfulResponseHandler preRetryHandler;
+
+    private RetryHandlerDecorator(
+        RetryUnsuccessfulResponseHandler retryHandler, HttpRequest request) {
+      this.retryHandler = checkNotNull(retryHandler);
+      HttpUnsuccessfulResponseHandler preRetryHandler = request.getUnsuccessfulResponseHandler();
+      if (preRetryHandler == null) {
+        preRetryHandler = new HttpUnsuccessfulResponseHandler() {
+          @Override
+          public boolean handleResponse(
+              HttpRequest request, HttpResponse response, boolean supportsRetry) {
+            return false;
+          }
+        };
+      }
+      this.preRetryHandler = preRetryHandler;
+    }
+
+    @Override
+    public boolean handleResponse(
+        HttpRequest request,
+        HttpResponse response,
+        boolean supportsRetry) throws IOException {
+      try {
+        boolean retry = preRetryHandler.handleResponse(request, response, supportsRetry);
+        if (!retry) {
+          retry = retryHandler.handleResponse(request, response, supportsRetry);
+        }
+        return retry;
+      } finally {
+        // Pre-retry handler may have reset the unsuccessful response handler on the
+        // request. This changes it back.
+        request.setUnsuccessfulResponseHandler(this);
+      }
+    }
+
+    RetryUnsuccessfulResponseHandler getRetryHandler() {
+      return retryHandler;
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandler.java b/src/main/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandler.java
new file mode 100644
index 0000000..dd00d2b
--- /dev/null
+++ b/src/main/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandler.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.internal;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpUnsuccessfulResponseHandler;
+import com.google.api.client.util.BackOff;
+import com.google.api.client.util.BackOffUtils;
+import com.google.api.client.util.Clock;
+import com.google.api.client.util.Sleeper;
+import com.google.common.base.Strings;
+import java.io.IOException;
+import java.util.Date;
+
+/**
+ * An {@code HttpUnsuccessfulResponseHandler} that retries failing requests after an interval. The
+ * interval is determined by checking the Retry-After header on the last response. If that
+ * header is not present, uses exponential back off to delay subsequent retries.
+ */
+final class RetryUnsuccessfulResponseHandler implements HttpUnsuccessfulResponseHandler {
+
+  private final RetryConfig retryConfig;
+  private final BackOff backOff;
+  private final Sleeper sleeper;
+  private final Clock clock;
+
+  RetryUnsuccessfulResponseHandler(RetryConfig retryConfig) {
+    this(retryConfig, Clock.SYSTEM);
+  }
+
+  RetryUnsuccessfulResponseHandler(RetryConfig retryConfig, Clock clock) {
+    this.retryConfig = checkNotNull(retryConfig);
+    this.backOff = retryConfig.newBackOff();
+    this.sleeper = retryConfig.getSleeper();
+    this.clock = checkNotNull(clock);
+  }
+
+  @Override
+  public boolean handleResponse(
+      HttpRequest request, HttpResponse response, boolean supportsRetry) throws IOException {
+
+    if (!supportsRetry) {
+      return false;
+    }
+
+    int statusCode = response.getStatusCode();
+    if (!retryConfig.getRetryStatusCodes().contains(statusCode)) {
+      return false;
+    }
+
+    try {
+      return waitAndRetry(response);
+    } catch (InterruptedException e) {
+      // ignore
+    }
+    return false;
+  }
+
+  RetryConfig getRetryConfig() {
+    return retryConfig;
+  }
+
+  private boolean waitAndRetry(HttpResponse response) throws IOException, InterruptedException {
+    String retryAfterHeader = response.getHeaders().getRetryAfter();
+    if (!Strings.isNullOrEmpty(retryAfterHeader)) {
+      long intervalMillis = parseRetryAfterHeaderIntoMillis(retryAfterHeader.trim());
+      // Retry-after header can specify very long delay intervals (e.g. 24 hours). If we cannot
+      // wait that long, we should not perform any retries at all. In general it is not correct to
+      // retry earlier than what the server has recommended to us.
+      if (intervalMillis > retryConfig.getMaxIntervalMillis()) {
+        return false;
+      }
+
+      if (intervalMillis > 0) {
+        sleeper.sleep(intervalMillis);
+        return true;
+      }
+    }
+
+    return BackOffUtils.next(sleeper, backOff);
+  }
+
+  private long parseRetryAfterHeaderIntoMillis(String retryAfter) {
+    try {
+      return Long.parseLong(retryAfter) * 1000;
+    } catch (NumberFormatException e) {
+      Date date = DateUtils.parseDate(retryAfter);
+      if (date != null) {
+        return date.getTime() - clock.currentTimeMillis();
+      }
+    }
+
+    return -1L;
+  }
+}


Test patch:
diff --git a/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java b/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
index b01eeb9..badb9ea 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
@@ -79,7 +79,7 @@ public class FirebaseAuthIT {
       "https://www.googleapis.com/identitytoolkit/v3/relyingparty/emailLinkSignin";
   private static final JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
   private static final HttpTransport transport = Utils.getDefaultTransport();
-  public static final String ACTION_LINK_CONTINUE_URL = "http://localhost/?a=1&b=2#c=3";
+  private static final String ACTION_LINK_CONTINUE_URL = "http://localhost/?a=1&b=2#c=3";
 
   private static FirebaseAuth auth;
 
@@ -406,7 +406,7 @@ public class FirebaseAuthIT {
       fail("expecting exception");
     } catch (ExecutionException e) {
       assertTrue(e.getCause() instanceof FirebaseAuthException);
-      assertEquals(FirebaseUserManager.ID_TOKEN_REVOKED_ERROR,
+      assertEquals(RevocationCheckDecorator.ID_TOKEN_REVOKED_ERROR,
                    ((FirebaseAuthException) e.getCause()).getErrorCode());
     }
     idToken = signInWithCustomToken(customToken);
@@ -440,7 +440,7 @@ public class FirebaseAuthIT {
       fail("expecting exception");
     } catch (ExecutionException e) {
       assertTrue(e.getCause() instanceof FirebaseAuthException);
-      assertEquals(FirebaseUserManager.SESSION_COOKIE_REVOKED_ERROR,
+      assertEquals(RevocationCheckDecorator.SESSION_COOKIE_REVOKED_ERROR,
           ((FirebaseAuthException) e.getCause()).getErrorCode());
     }
 
diff --git a/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java b/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
index 8f19e5c..c495c41 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
@@ -16,6 +16,7 @@
 
 package com.google.firebase.auth;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNotSame;
@@ -25,9 +26,13 @@ import static org.junit.Assert.fail;
 
 import com.google.api.core.ApiFuture;
 import com.google.common.base.Defaults;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableMap;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.FirebaseOptions;
 import com.google.firebase.TestOnlyImplFirebaseTrampolines;
+import com.google.firebase.auth.internal.FirebaseTokenFactory;
 import com.google.firebase.testing.ServiceAccount;
 import com.google.firebase.testing.TestUtils;
 import java.lang.reflect.InvocationTargetException;
@@ -38,10 +43,10 @@ import java.util.List;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
 
 import org.junit.After;
 import org.junit.Assert;
-import org.junit.Before;
 import org.junit.Test;
 
 public class FirebaseAuthTest {
@@ -50,11 +55,6 @@ public class FirebaseAuthTest {
       .setCredentials(TestUtils.getCertCredential(ServiceAccount.EDITOR.asStream()))
       .build();
 
-  @Before
-  public void setup() {
-    FirebaseApp.initializeApp(firebaseOptions);
-  }
-
   @After
   public void cleanup() {
     TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
@@ -62,6 +62,7 @@ public class FirebaseAuthTest {
 
   @Test
   public void testGetInstance() {
+    FirebaseApp.initializeApp(firebaseOptions);
     FirebaseAuth defaultAuth = FirebaseAuth.getInstance();
     assertNotNull(defaultAuth);
     assertSame(defaultAuth, FirebaseAuth.getInstance());
@@ -164,4 +165,354 @@ public class FirebaseAuthTest {
   public void testAuthExceptionEmptyErrorCode() {
     new FirebaseAuthException("", "test");
   }
+
+  @Test
+  public void testDefaultIdTokenVerifier() {
+    FirebaseApp.initializeApp(firebaseOptions);
+
+    FirebaseTokenVerifier tokenVerifier = FirebaseAuth.getInstance()
+        .getIdTokenVerifier(false);
+
+    assertTrue(tokenVerifier instanceof FirebaseTokenVerifierImpl);
+    String shortName = ((FirebaseTokenVerifierImpl) tokenVerifier).getShortName();
+    assertEquals("ID token", shortName);
+  }
+
+  @Test
+  public void testIdTokenVerifierInitializedOnDemand() throws Exception {
+    FirebaseTokenVerifier tokenVerifier = MockTokenVerifier.fromResult(
+        getFirebaseToken("idTokenUser"));
+    CountingSupplier<FirebaseTokenVerifier> countingSupplier = new CountingSupplier<>(
+        Suppliers.ofInstance(tokenVerifier));
+
+    FirebaseAuth auth = getAuthForIdTokenVerification(countingSupplier);
+    assertEquals(0, countingSupplier.getCount());
+
+    auth.verifyIdToken("idToken");
+    auth.verifyIdToken("idToken");
+
+    assertEquals(1, countingSupplier.getCount());
+  }
+
+  @Test
+  public void testVerifyIdTokenWithNull() {
+    MockTokenVerifier tokenVerifier = MockTokenVerifier.fromResult(null);
+    tokenVerifier.lastTokenString = "_init_";
+    FirebaseAuth auth = getAuthForIdTokenVerification(tokenVerifier);
+
+    try {
+      auth.verifyIdTokenAsync(null);
+      fail("No error thrown for null id token");
+    } catch (IllegalArgumentException expected) {
+      assertEquals("_init_", tokenVerifier.getLastTokenString());
+    }
+  }
+
+  @Test
+  public void testVerifyIdTokenWithEmptyString() {
+    MockTokenVerifier tokenVerifier = MockTokenVerifier.fromResult(null);
+    tokenVerifier.lastTokenString = "_init_";
+    FirebaseAuth auth = getAuthForIdTokenVerification(tokenVerifier);
+
+    try {
+      auth.verifyIdTokenAsync("");
+      fail("No error thrown for null id token");
+    } catch (IllegalArgumentException expected) {
+      assertEquals("_init_", tokenVerifier.getLastTokenString());
+    }
+
+  }
+
+  @Test
+  public void testVerifyIdToken() throws Exception {
+    MockTokenVerifier tokenVerifier = MockTokenVerifier.fromResult(
+        getFirebaseToken("testUser"));
+    FirebaseAuth auth = getAuthForIdTokenVerification(tokenVerifier);
+
+    FirebaseToken firebaseToken = auth.verifyIdToken("idtoken");
+
+    assertEquals("testUser", firebaseToken.getUid());
+    assertEquals("idtoken", tokenVerifier.getLastTokenString());
+  }
+
+  @Test
+  public void testVerifyIdTokenFailure() {
+    MockTokenVerifier tokenVerifier = MockTokenVerifier.fromException(
+        new FirebaseAuthException("TEST_CODE", "Test error message"));
+    FirebaseAuth auth = getAuthForIdTokenVerification(tokenVerifier);
+
+    try {
+      auth.verifyIdToken("idtoken");
+      fail("No error thrown for invalid token");
+    } catch (FirebaseAuthException authException) {
+      assertEquals("TEST_CODE", authException.getErrorCode());
+      assertEquals("Test error message", authException.getMessage());
+      assertEquals("idtoken", tokenVerifier.getLastTokenString());
+    }
+  }
+
+  @Test
+  public void testVerifyIdTokenAsync() throws Exception {
+    MockTokenVerifier tokenVerifier = MockTokenVerifier.fromResult(
+        getFirebaseToken("testUser"));
+    FirebaseAuth auth = getAuthForIdTokenVerification(tokenVerifier);
+
+    FirebaseToken firebaseToken = auth.verifyIdTokenAsync("idtoken").get();
+
+    assertEquals("testUser", firebaseToken.getUid());
+    assertEquals("idtoken", tokenVerifier.getLastTokenString());
+  }
+
+  @Test
+  public void testVerifyIdTokenAsyncFailure() throws InterruptedException {
+    MockTokenVerifier tokenVerifier = MockTokenVerifier.fromException(
+        new FirebaseAuthException("TEST_CODE", "Test error message"));
+    FirebaseAuth auth = getAuthForIdTokenVerification(tokenVerifier);
+
+    try {
+      auth.verifyIdTokenAsync("idtoken").get();
+      fail("No error thrown for invalid token");
+    } catch (ExecutionException e) {
+      FirebaseAuthException authException = (FirebaseAuthException) e.getCause();
+      assertEquals("TEST_CODE", authException.getErrorCode());
+      assertEquals("Test error message", authException.getMessage());
+      assertEquals("idtoken", tokenVerifier.getLastTokenString());
+    }
+  }
+
+  @Test
+  public void testVerifyIdTokenWithCheckRevokedAsyncFailure() throws InterruptedException {
+    MockTokenVerifier tokenVerifier = MockTokenVerifier.fromException(
+        new FirebaseAuthException("TEST_CODE", "Test error message"));
+    FirebaseAuth auth = getAuthForIdTokenVerification(tokenVerifier);
+
+    try {
+      auth.verifyIdTokenAsync("idtoken", true).get();
+      fail("No error thrown for invalid token");
+    } catch (ExecutionException e) {
+      FirebaseAuthException authException = (FirebaseAuthException) e.getCause();
+      assertEquals("TEST_CODE", authException.getErrorCode());
+      assertEquals("Test error message", authException.getMessage());
+      assertEquals("idtoken", tokenVerifier.getLastTokenString());
+    }
+  }
+
+  @Test
+  public void testDefaultSessionCookieVerifier() {
+    FirebaseApp.initializeApp(firebaseOptions);
+
+    FirebaseTokenVerifier tokenVerifier = FirebaseAuth.getInstance()
+        .getSessionCookieVerifier(false);
+
+    assertTrue(tokenVerifier instanceof FirebaseTokenVerifierImpl);
+    String shortName = ((FirebaseTokenVerifierImpl) tokenVerifier).getShortName();
+    assertEquals("session cookie", shortName);
+  }
+
+  @Test
+  public void testSessionCookieVerifierInitializedOnDemand() throws Exception {
+    FirebaseTokenVerifier tokenVerifier = MockTokenVerifier.fromResult(
+        getFirebaseToken("cookieUser"));
+    CountingSupplier<FirebaseTokenVerifier> countingSupplier = new CountingSupplier<>(
+        Suppliers.ofInstance(tokenVerifier));
+    FirebaseAuth auth = getAuthForSessionCookieVerification(countingSupplier);
+
+    assertEquals(0, countingSupplier.getCount());
+
+    auth.verifySessionCookie("sessionCookie");
+    auth.verifySessionCookie("sessionCookie");
+
+    assertEquals(1, countingSupplier.getCount());
+  }
+
+  @Test
+  public void testVerifySessionCookieWithNull() {
+    MockTokenVerifier tokenVerifier = MockTokenVerifier.fromResult(null);
+    tokenVerifier.lastTokenString = "_init_";
+    FirebaseAuth auth = getAuthForSessionCookieVerification(tokenVerifier);
+
+    try {
+      auth.verifySessionCookieAsync(null);
+      fail("No error thrown for null id token");
+    } catch (IllegalArgumentException expected) {
+      assertEquals("_init_", tokenVerifier.getLastTokenString());
+    }
+  }
+
+  @Test
+  public void testVerifySessionCookieWithEmptyString() {
+    MockTokenVerifier tokenVerifier = MockTokenVerifier.fromResult(null);
+    tokenVerifier.lastTokenString = "_init_";
+    FirebaseAuth auth = getAuthForSessionCookieVerification(tokenVerifier);
+
+    try {
+      auth.verifySessionCookieAsync("");
+      fail("No error thrown for null id token");
+    } catch (IllegalArgumentException expected) {
+      assertEquals("_init_", tokenVerifier.getLastTokenString());
+    }
+
+  }
+
+  @Test
+  public void testVerifySessionCookie() throws Exception {
+    MockTokenVerifier tokenVerifier = MockTokenVerifier.fromResult(
+        getFirebaseToken("testUser"));
+    FirebaseAuth auth = getAuthForSessionCookieVerification(tokenVerifier);
+
+    FirebaseToken firebaseToken = auth.verifySessionCookie("idtoken");
+
+    assertEquals("testUser", firebaseToken.getUid());
+    assertEquals("idtoken", tokenVerifier.getLastTokenString());
+  }
+
+  @Test
+  public void testVerifySessionCookieFailure() {
+    MockTokenVerifier tokenVerifier = MockTokenVerifier.fromException(
+        new FirebaseAuthException("TEST_CODE", "Test error message"));
+    FirebaseAuth auth = getAuthForSessionCookieVerification(tokenVerifier);
+
+    try {
+      auth.verifySessionCookie("idtoken");
+      fail("No error thrown for invalid token");
+    } catch (FirebaseAuthException authException) {
+      assertEquals("TEST_CODE", authException.getErrorCode());
+      assertEquals("Test error message", authException.getMessage());
+      assertEquals("idtoken", tokenVerifier.getLastTokenString());
+    }
+  }
+
+  @Test
+  public void testVerifySessionCookieAsync() throws Exception {
+    MockTokenVerifier tokenVerifier = MockTokenVerifier.fromResult(
+        getFirebaseToken("testUser"));
+    FirebaseAuth auth = getAuthForSessionCookieVerification(tokenVerifier);
+
+    FirebaseToken firebaseToken = auth.verifySessionCookieAsync("idtoken").get();
+
+    assertEquals("testUser", firebaseToken.getUid());
+    assertEquals("idtoken", tokenVerifier.getLastTokenString());
+  }
+
+  @Test
+  public void testVerifySessionCookieAsyncFailure() throws InterruptedException {
+    MockTokenVerifier tokenVerifier = MockTokenVerifier.fromException(
+        new FirebaseAuthException("TEST_CODE", "Test error message"));
+    FirebaseAuth auth = getAuthForSessionCookieVerification(tokenVerifier);
+
+    try {
+      auth.verifySessionCookieAsync("idtoken").get();
+      fail("No error thrown for invalid token");
+    } catch (ExecutionException e) {
+      FirebaseAuthException authException = (FirebaseAuthException) e.getCause();
+      assertEquals("TEST_CODE", authException.getErrorCode());
+      assertEquals("Test error message", authException.getMessage());
+      assertEquals("idtoken", tokenVerifier.getLastTokenString());
+    }
+  }
+
+  @Test
+  public void testVerifySessionCookieWithCheckRevokedAsyncFailure() throws InterruptedException {
+    MockTokenVerifier tokenVerifier = MockTokenVerifier.fromException(
+        new FirebaseAuthException("TEST_CODE", "Test error message"));
+    FirebaseAuth auth = getAuthForSessionCookieVerification(tokenVerifier);
+
+    try {
+      auth.verifySessionCookieAsync("idtoken", true).get();
+      fail("No error thrown for invalid token");
+    } catch (ExecutionException e) {
+      FirebaseAuthException authException = (FirebaseAuthException) e.getCause();
+      assertEquals("TEST_CODE", authException.getErrorCode());
+      assertEquals("Test error message", authException.getMessage());
+      assertEquals("idtoken", tokenVerifier.getLastTokenString());
+    }
+  }
+
+  private FirebaseToken getFirebaseToken(String subject) {
+    return new FirebaseToken(ImmutableMap.<String, Object>of("sub", subject));
+  }
+
+  private FirebaseAuth getAuthForIdTokenVerification(FirebaseTokenVerifier tokenVerifier) {
+    return getAuthForIdTokenVerification(Suppliers.ofInstance(tokenVerifier));
+  }
+
+  private FirebaseAuth getAuthForIdTokenVerification(
+      Supplier<? extends FirebaseTokenVerifier> tokenVerifierSupplier) {
+    FirebaseApp app = FirebaseApp.initializeApp(firebaseOptions);
+    return FirebaseAuth.builder()
+        .setFirebaseApp(app)
+        .setTokenFactory(Suppliers.<FirebaseTokenFactory>ofInstance(null))
+        .setIdTokenVerifier(tokenVerifierSupplier)
+        .setCookieVerifier(Suppliers.<FirebaseTokenVerifier>ofInstance(null))
+        .build();
+  }
+
+  private FirebaseAuth getAuthForSessionCookieVerification(FirebaseTokenVerifier tokenVerifier) {
+    return getAuthForSessionCookieVerification(Suppliers.ofInstance(tokenVerifier));
+  }
+
+  private FirebaseAuth getAuthForSessionCookieVerification(
+      Supplier<? extends FirebaseTokenVerifier> tokenVerifierSupplier) {
+    FirebaseApp app = FirebaseApp.initializeApp(firebaseOptions);
+    return FirebaseAuth.builder()
+        .setFirebaseApp(app)
+        .setTokenFactory(Suppliers.<FirebaseTokenFactory>ofInstance(null))
+        .setIdTokenVerifier(Suppliers.<FirebaseTokenVerifier>ofInstance(null))
+        .setCookieVerifier(tokenVerifierSupplier)
+        .build();
+  }
+
+  private static class MockTokenVerifier implements FirebaseTokenVerifier {
+
+    private String lastTokenString;
+
+    private FirebaseToken result;
+    private FirebaseAuthException exception;
+
+    private MockTokenVerifier(FirebaseToken result, FirebaseAuthException exception) {
+      this.result = result;
+      this.exception = exception;
+    }
+
+    @Override
+    public FirebaseToken verifyToken(String token) throws FirebaseAuthException {
+      lastTokenString = token;
+      if (exception != null) {
+        throw exception;
+      }
+      return result;
+    }
+
+    String getLastTokenString() {
+      return this.lastTokenString;
+    }
+
+    static MockTokenVerifier fromResult(FirebaseToken result) {
+      return new MockTokenVerifier(result, null);
+    }
+
+    static MockTokenVerifier fromException(FirebaseAuthException exception) {
+      return new MockTokenVerifier(null, exception);
+    }
+  }
+
+  private static class CountingSupplier<T> implements Supplier<T> {
+
+    private final AtomicInteger counter = new AtomicInteger(0);
+    private final Supplier<T> supplier;
+
+    CountingSupplier(Supplier<T> supplier) {
+      this.supplier = checkNotNull(supplier);
+    }
+
+    @Override
+    public T get() {
+      counter.incrementAndGet();
+      return supplier.get();
+    }
+
+    int getCount() {
+      return counter.get();
+    }
+  }
 }
diff --git a/src/test/java/com/google/firebase/auth/FirebaseTokenTest.java b/src/test/java/com/google/firebase/auth/FirebaseTokenTest.java
new file mode 100644
index 0000000..af65561
--- /dev/null
+++ b/src/test/java/com/google/firebase/auth/FirebaseTokenTest.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright  2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.auth;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.api.client.auth.openidconnect.IdToken;
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+import org.junit.Test;
+
+public class FirebaseTokenTest {
+
+  @Test
+  public void testFirebaseToken() {
+    Map<String, Object> claims = ImmutableMap.<String, Object>builder()
+        .put("sub", "testUser")
+        .put("iss", "test-project-id")
+        .put("email", "test@example.com")
+        .put("email_verified", true)
+        .put("name", "Test User")
+        .put("picture", "https://picture.url")
+        .put("custom", "claim")
+        .build();
+
+    FirebaseToken firebaseToken = new FirebaseToken(claims);
+
+    assertEquals("testUser", firebaseToken.getUid());
+    assertEquals("test-project-id", firebaseToken.getIssuer());
+    assertEquals("test@example.com", firebaseToken.getEmail());
+    assertTrue(firebaseToken.isEmailVerified());
+    assertEquals("Test User", firebaseToken.getName());
+    assertEquals("https://picture.url", firebaseToken.getPicture());
+    assertEquals("claim", firebaseToken.getClaims().get("custom"));
+    assertEquals(7, firebaseToken.getClaims().size());
+  }
+
+  @Test
+  public void testFirebaseTokenMinimal() {
+    Map<String, Object> claims = ImmutableMap.<String, Object>builder()
+        .put("sub", "testUser")
+        .build();
+
+    FirebaseToken firebaseToken = new FirebaseToken(claims);
+
+    assertEquals("testUser", firebaseToken.getUid());
+    assertNull(firebaseToken.getIssuer());
+    assertNull(firebaseToken.getEmail());
+    assertFalse(firebaseToken.isEmailVerified());
+    assertNull(firebaseToken.getName());
+    assertNull(firebaseToken.getPicture());
+    assertEquals(1, firebaseToken.getClaims().size());
+  }
+
+  @Test
+  public void testFirebaseTokenFromIdToken() {
+    IdToken.Payload payload = new IdToken.Payload()
+        .setSubject("testUser")
+        .setIssuer("test-project-id")
+        .set("email", "test@example.com")
+        .set("email_verified", true)
+        .set("name", "Test User")
+        .set("picture", "https://picture.url")
+        .set("custom", "claim");
+
+    FirebaseToken firebaseToken = new FirebaseToken(payload);
+
+    assertEquals("testUser", firebaseToken.getUid());
+    assertEquals("test-project-id", firebaseToken.getIssuer());
+    assertEquals("test@example.com", firebaseToken.getEmail());
+    assertTrue(firebaseToken.isEmailVerified());
+    assertEquals("Test User", firebaseToken.getName());
+    assertEquals("https://picture.url", firebaseToken.getPicture());
+    assertEquals("claim", firebaseToken.getClaims().get("custom"));
+    assertEquals(7, firebaseToken.getClaims().size());
+  }
+
+  @Test
+  public void testFirebaseTokenFromMinimalIdToken() {
+    IdToken.Payload payload = new IdToken.Payload()
+        .setSubject("testUser");
+
+    FirebaseToken firebaseToken = new FirebaseToken(payload);
+
+    assertEquals("testUser", firebaseToken.getUid());
+    assertNull(firebaseToken.getIssuer());
+    assertNull(firebaseToken.getEmail());
+    assertFalse(firebaseToken.isEmailVerified());
+    assertNull(firebaseToken.getName());
+    assertNull(firebaseToken.getPicture());
+    assertEquals(1, firebaseToken.getClaims().size());
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testFirebaseTokenNullClaims() {
+    new FirebaseToken(null);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testFirebaseTokenNoUid() {
+    ImmutableMap<String, Object> claimsWithoutSub = ImmutableMap.<String, Object>of(
+        "custom", "claim");
+    new FirebaseToken(claimsWithoutSub);
+  }
+}
diff --git a/src/test/java/com/google/firebase/auth/FirebaseTokenUtilsTest.java b/src/test/java/com/google/firebase/auth/FirebaseTokenUtilsTest.java
new file mode 100644
index 0000000..f452091
--- /dev/null
+++ b/src/test/java/com/google/firebase/auth/FirebaseTokenUtilsTest.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright  2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.auth;
+
+import static junit.framework.TestCase.assertNotNull;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import com.google.api.client.auth.openidconnect.IdTokenVerifier;
+import com.google.api.client.googleapis.auth.oauth2.GooglePublicKeysManager;
+import com.google.api.client.json.gson.GsonFactory;
+import com.google.api.client.testing.http.FixedClock;
+import com.google.api.client.util.Clock;
+import com.google.auth.oauth2.GoogleCredentials;
+import com.google.common.collect.Iterables;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.TestOnlyImplFirebaseTrampolines;
+import org.junit.After;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class FirebaseTokenUtilsTest {
+
+  private static final Clock CLOCK = new FixedClock(2002000L * 1000);
+  private static final String TEST_PROJECT_ID = "test-project-id";
+  private static final GoogleCredentials MOCK_CREDENTIALS = GoogleCredentials.create(null);
+
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  @After
+  public void tearDown() {
+    TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
+  }
+
+  @Test
+  public void testCreateIdTokenVerifier() {
+    FirebaseApp app = FirebaseApp.initializeApp(FirebaseOptions.builder()
+        .setCredentials(MOCK_CREDENTIALS)
+        .setProjectId(TEST_PROJECT_ID)
+        .build());
+
+    FirebaseTokenVerifierImpl idTokenVerifier = FirebaseTokenUtils.createIdTokenVerifier(
+        app, CLOCK);
+
+    assertEquals("verifyIdToken()", idTokenVerifier.getMethod());
+    assertEquals("ID token", idTokenVerifier.getShortName());
+    assertEquals("an ID token", idTokenVerifier.getArticledShortName());
+    assertEquals("https://firebase.google.com/docs/auth/admin/verify-id-tokens",
+        idTokenVerifier.getDocUrl());
+    verifyPublicKeysManager(idTokenVerifier.getPublicKeysManager(),
+        "https://www.googleapis.com/robot/v1/metadata/x509/"
+            + "securetoken@system.gserviceaccount.com");
+    verifyJwtVerifier(idTokenVerifier.getIdTokenVerifier(),
+        "https://securetoken.google.com/test-project-id");
+  }
+
+  @Test
+  public void testCreateIdTokenVerifierWithoutProjectId() {
+    FirebaseApp app = FirebaseApp.initializeApp(FirebaseOptions.builder()
+        .setCredentials(MOCK_CREDENTIALS)
+        .build());
+
+    thrown.expectMessage("Must initialize FirebaseApp with a project ID to call verifyIdToken()");
+    FirebaseTokenUtils.createIdTokenVerifier(app, CLOCK);
+  }
+
+  @Test
+  public void testSessionCookieVerifier() {
+    FirebaseApp app = FirebaseApp.initializeApp(FirebaseOptions.builder()
+        .setCredentials(MOCK_CREDENTIALS)
+        .setProjectId(TEST_PROJECT_ID)
+        .build());
+
+    FirebaseTokenVerifierImpl cookieVerifier = FirebaseTokenUtils.createSessionCookieVerifier(
+        app, CLOCK);
+
+    assertEquals("verifySessionCookie()", cookieVerifier.getMethod());
+    assertEquals("session cookie", cookieVerifier.getShortName());
+    assertEquals("a session cookie", cookieVerifier.getArticledShortName());
+    assertEquals("https://firebase.google.com/docs/auth/admin/manage-cookies",
+        cookieVerifier.getDocUrl());
+    verifyPublicKeysManager(cookieVerifier.getPublicKeysManager(),
+        "https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys");
+    verifyJwtVerifier(cookieVerifier.getIdTokenVerifier(),
+        "https://session.firebase.google.com/test-project-id");
+  }
+
+  @Test
+  public void testCreateSessionCookieVerifierWithoutProjectId() {
+    FirebaseApp app = FirebaseApp.initializeApp(FirebaseOptions.builder()
+        .setCredentials(MOCK_CREDENTIALS)
+        .build());
+
+    thrown.expectMessage("Must initialize FirebaseApp with a project ID to call "
+        + "verifySessionCookie()");
+    FirebaseTokenUtils.createSessionCookieVerifier(app, CLOCK);
+  }
+
+  private void verifyPublicKeysManager(GooglePublicKeysManager publicKeysManager, String certUrl) {
+    assertNotNull(publicKeysManager);
+    assertEquals(certUrl, publicKeysManager.getPublicCertsEncodedUrl());
+    assertSame(CLOCK, publicKeysManager.getClock());
+    assertTrue(publicKeysManager.getJsonFactory() instanceof GsonFactory);
+  }
+
+  private void verifyJwtVerifier(IdTokenVerifier jwtVerifier, String issuer) {
+    assertNotNull(jwtVerifier);
+    assertEquals(issuer, jwtVerifier.getIssuer());
+    assertEquals(TEST_PROJECT_ID, Iterables.getOnlyElement(jwtVerifier.getAudience()));
+    assertSame(CLOCK, jwtVerifier.getClock());
+  }
+}
diff --git a/src/test/java/com/google/firebase/auth/FirebaseTokenVerifierImplTest.java b/src/test/java/com/google/firebase/auth/FirebaseTokenVerifierImplTest.java
new file mode 100644
index 0000000..b10817a
--- /dev/null
+++ b/src/test/java/com/google/firebase/auth/FirebaseTokenVerifierImplTest.java
@@ -0,0 +1,340 @@
+/*
+ * Copyright  2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.auth;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.google.api.client.auth.openidconnect.IdTokenVerifier;
+import com.google.api.client.googleapis.auth.oauth2.GooglePublicKeysManager;
+import com.google.api.client.http.HttpTransport;
+import com.google.api.client.http.LowLevelHttpRequest;
+import com.google.api.client.json.webtoken.JsonWebSignature;
+import com.google.api.client.json.webtoken.JsonWebToken.Payload;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.firebase.testing.ServiceAccount;
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class FirebaseTokenVerifierImplTest {
+
+  private static final String CUSTOM_TOKEN_AUDIENCE =
+      "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit"
+          + ".v1.IdentityToolkit";
+
+  private static final String LEGACY_CUSTOM_TOKEN =
+      "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJkIjp7"
+          + "InVpZCI6IjEiLCJhYmMiOiIwMTIzNDU2Nzg5fiFAIyQlXiYqKClfKy09YWJjZGVmZ2hpamtsbW5vcHF"
+          + "yc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWiwuLzsnW11cXDw"
+          + "-P1wie318In0sInYiOjAsImlhdCI6MTQ4MDk4Mj"
+          + "U2NH0.ZWEpoHgIPCAz8Q-cNFBS8jiqClTJ3j27yuRkQo-QxyI";
+
+  private static final String TEST_TOKEN_ISSUER = "https://test.token.issuer";
+
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  private FirebaseTokenVerifier tokenVerifier;
+  private TestTokenFactory tokenFactory;
+
+  @Before
+  public void setUp() throws Exception {
+    ServiceAccount serviceAccount = ServiceAccount.EDITOR;
+    GooglePublicKeysManager publicKeysManager = newPublicKeysManager(serviceAccount.getCert());
+    this.tokenVerifier = newTestTokenVerifier(publicKeysManager);
+    this.tokenFactory = new TestTokenFactory(serviceAccount.getPrivateKey(), TEST_TOKEN_ISSUER);
+  }
+
+  @Test
+  public void testVerifyToken() throws Exception {
+    String token = tokenFactory.createToken();
+
+    FirebaseToken firebaseToken = tokenVerifier.verifyToken(token);
+
+    assertEquals(TEST_TOKEN_ISSUER, firebaseToken.getIssuer());
+    assertEquals(TestTokenFactory.UID, firebaseToken.getUid());
+  }
+
+  @Test
+  public void testVerifyTokenWithoutKeyId() throws Exception {
+    String token = createTokenWithoutKeyId();
+
+    thrown.expectMessage("Firebase test token has no \"kid\" claim.");
+    tokenVerifier.verifyToken(token);
+  }
+
+  @Test
+  public void testVerifyTokenFirebaseCustomToken() throws Exception {
+    String token = createCustomToken();
+
+    thrown.expectMessage("verifyTestToken() expects a test token, but was given a custom token.");
+    tokenVerifier.verifyToken(token);
+  }
+
+  @Test
+  public void testVerifyTokenIncorrectAlgorithm() throws Exception {
+    String token = createTokenWithIncorrectAlgorithm();
+
+    thrown.expectMessage("Firebase test token has incorrect algorithm.");
+    tokenVerifier.verifyToken(token);
+  }
+
+  @Test
+  public void testVerifyTokenIncorrectAudience() throws Exception {
+    String token = createTokenWithIncorrectAudience();
+
+    thrown.expectMessage("Firebase test token has incorrect \"aud\" (audience) claim.");
+    tokenVerifier.verifyToken(token);
+  }
+
+  @Test
+  public void testVerifyTokenIncorrectIssuer() throws Exception {
+    String token = createTokenWithIncorrectIssuer();
+
+    thrown.expectMessage("Firebase test token has incorrect \"iss\" (issuer) claim.");
+    tokenVerifier.verifyToken(token);
+  }
+
+  @Test
+  public void testVerifyTokenMissingSubject() throws Exception {
+    String token = createTokenWithSubject(null);
+
+    thrown.expectMessage("Firebase test token has no \"sub\" (subject) claim.");
+    tokenVerifier.verifyToken(token);
+  }
+
+  @Test
+  public void testVerifyTokenEmptySubject() throws Exception {
+    String token = createTokenWithSubject("");
+
+    thrown.expectMessage("Firebase test token has an empty string \"sub\" (subject) claim.");
+    tokenVerifier.verifyToken(token);
+  }
+
+  @Test
+  public void testVerifyTokenLongSubject() throws Exception {
+    String token = createTokenWithSubject(Strings.repeat("a", 129));
+
+    thrown.expectMessage(
+        "Firebase test token has \"sub\" (subject) claim longer than 128 characters.");
+    tokenVerifier.verifyToken(token);
+  }
+
+  @Test
+  public void testVerifyTokenIssuedAtInFuture() throws Exception {
+    long tenMinutesIntoTheFuture = (TestTokenFactory.CLOCK.currentTimeMillis() / 1000)
+        + TimeUnit.MINUTES.toSeconds(10);
+    String token = createTokenWithTimestamps(
+        tenMinutesIntoTheFuture,
+        tenMinutesIntoTheFuture + TimeUnit.HOURS.toSeconds(1));
+
+    thrown.expectMessage("Firebase test token has expired or is not yet valid.");
+    tokenVerifier.verifyToken(token);
+  }
+
+  @Test
+  public void testVerifyTokenExpired() throws Exception {
+    long twoHoursInPast = (TestTokenFactory.CLOCK.currentTimeMillis() / 1000)
+        - TimeUnit.HOURS.toSeconds(2);
+    String token = createTokenWithTimestamps(
+        twoHoursInPast,
+        twoHoursInPast + TimeUnit.HOURS.toSeconds(1));
+
+    thrown.expectMessage("Firebase test token has expired or is not yet valid.");
+    tokenVerifier.verifyToken(token);
+  }
+
+  @Test
+  public void testVerifyTokenIncorrectCert() throws Exception {
+    String token = tokenFactory.createToken();
+    GooglePublicKeysManager publicKeysManager = newPublicKeysManager(
+        ServiceAccount.NONE.getCert());
+    FirebaseTokenVerifier tokenVerifier = newTestTokenVerifier(publicKeysManager);
+
+    thrown.expectMessage("Failed to verify the signature of Firebase test token. "
+        + "See https://test.doc.url for details on how to retrieve a test token.");
+    tokenVerifier.verifyToken(token);
+  }
+
+  @Test
+  public void verifyTokenCertificateError() {
+    MockHttpTransport failingTransport = new MockHttpTransport() {
+      @Override
+      public LowLevelHttpRequest buildRequest(String method, String url) throws IOException {
+        throw new IOException("Expected error");
+      }
+    };
+    GooglePublicKeysManager publicKeysManager = newPublicKeysManager(failingTransport);
+    FirebaseTokenVerifier idTokenVerifier = newTestTokenVerifier(publicKeysManager);
+    String token = tokenFactory.createToken();
+
+    try {
+      idTokenVerifier.verifyToken(token);
+      Assert.fail("No exception thrown");
+    } catch (FirebaseAuthException expected) {
+      assertTrue(expected.getCause() instanceof IOException);
+      assertEquals("Expected error", expected.getCause().getMessage());
+    }
+  }
+
+  @Test
+  public void testLegacyCustomToken() throws Exception {
+    thrown.expectMessage(
+        "verifyTestToken() expects a test token, but was given a legacy custom token.");
+    tokenVerifier.verifyToken(LEGACY_CUSTOM_TOKEN);
+  }
+
+  @Test
+  public void testMalformedToken() throws Exception {
+    thrown.expectMessage(
+        "Failed to parse Firebase test token. Make sure you passed a string that represents a "
+            + "complete and valid JWT. See https://test.doc.url for details on how to retrieve "
+            + "a test token.");
+    tokenVerifier.verifyToken("not.a.jwt");
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testBuilderNoPublicKeysManager() {
+    fullyPopulatedBuilder().setPublicKeysManager(null).build();
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testBuilderNoJsonFactory() {
+    fullyPopulatedBuilder().setJsonFactory(null).build();
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testBuilderNoIdTokenVerifier() {
+    fullyPopulatedBuilder().setIdTokenVerifier(null).build();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testBuilderNoMethodName() {
+    fullyPopulatedBuilder().setMethod(null).build();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testBuilderNoShortName() {
+    fullyPopulatedBuilder().setShortName(null).build();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testBuilderNoDocUrl() {
+    fullyPopulatedBuilder().setDocUrl(null).build();
+  }
+
+  private GooglePublicKeysManager newPublicKeysManager(String certificate) {
+    String serviceAccountCertificates =
+        String.format("{\"%s\" : \"%s\"}", TestTokenFactory.PRIVATE_KEY_ID, certificate);
+    HttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(
+            new MockLowLevelHttpResponse().setContent(serviceAccountCertificates))
+        .build();
+    return newPublicKeysManager(transport);
+  }
+
+  private GooglePublicKeysManager newPublicKeysManager(HttpTransport transport) {
+    return new GooglePublicKeysManager.Builder(
+        transport, FirebaseTokenUtils.UNQUOTED_CTRL_CHAR_JSON_FACTORY)
+        .setClock(TestTokenFactory.CLOCK)
+        .setPublicCertsEncodedUrl("https://test.cert.url")
+        .build();
+  }
+
+  private FirebaseTokenVerifier newTestTokenVerifier(GooglePublicKeysManager publicKeysManager) {
+    return FirebaseTokenVerifierImpl.builder()
+        .setShortName("test token")
+        .setMethod("verifyTestToken()")
+        .setDocUrl("https://test.doc.url")
+        .setJsonFactory(TestTokenFactory.JSON_FACTORY)
+        .setPublicKeysManager(publicKeysManager)
+        .setIdTokenVerifier(newIdTokenVerifier())
+        .build();
+  }
+
+  private FirebaseTokenVerifierImpl.Builder fullyPopulatedBuilder() {
+    return FirebaseTokenVerifierImpl.builder()
+        .setShortName("test token")
+        .setMethod("verifyTestToken()")
+        .setDocUrl("https://test.doc.url")
+        .setJsonFactory(TestTokenFactory.JSON_FACTORY)
+        .setPublicKeysManager(newPublicKeysManager(ServiceAccount.EDITOR.getCert()))
+        .setIdTokenVerifier(newIdTokenVerifier());
+  }
+
+  private IdTokenVerifier newIdTokenVerifier() {
+    return new IdTokenVerifier.Builder()
+          .setClock(TestTokenFactory.CLOCK)
+          .setAudience(ImmutableList.of(TestTokenFactory.PROJECT_ID))
+          .setIssuer(TEST_TOKEN_ISSUER)
+          .build();
+  }
+
+  private String createTokenWithoutKeyId() {
+    JsonWebSignature.Header header = tokenFactory.createHeader();
+    header.setKeyId(null);
+    return tokenFactory.createToken(header);
+  }
+
+  private String createTokenWithSubject(String sub) {
+    Payload payload = tokenFactory.createTokenPayload();
+    payload.setSubject(sub);
+    return tokenFactory.createToken(payload);
+  }
+
+  private String createCustomToken() {
+    JsonWebSignature.Header header = tokenFactory.createHeader();
+    header.setKeyId(null);
+    Payload payload = tokenFactory.createTokenPayload();
+    payload.setAudience(CUSTOM_TOKEN_AUDIENCE);
+    return tokenFactory.createToken(header, payload);
+  }
+
+  private String createTokenWithIncorrectAlgorithm() {
+    JsonWebSignature.Header header = tokenFactory.createHeader();
+    header.setAlgorithm("HSA");
+    return tokenFactory.createToken(header);
+  }
+
+  private String createTokenWithIncorrectAudience() {
+    Payload payload = tokenFactory.createTokenPayload();
+    payload.setAudience("invalid-audience");
+    return tokenFactory.createToken(payload);
+  }
+
+  private String createTokenWithIncorrectIssuer() {
+    Payload payload = tokenFactory.createTokenPayload();
+    payload.setIssuer("https://incorrect.issuer.prefix/" + TestTokenFactory.PROJECT_ID);
+    return tokenFactory.createToken(payload);
+  }
+
+  private String createTokenWithTimestamps(long issuedAtSeconds, long expirationSeconds) {
+    Payload payload = tokenFactory.createTokenPayload();
+    payload.setIssuedAtTimeSeconds(issuedAtSeconds);
+    payload.setExpirationTimeSeconds(expirationSeconds);
+    return tokenFactory.createToken(payload);
+  }
+}
diff --git a/src/test/java/com/google/firebase/auth/TestOnlyImplFirebaseAuthTrampolines.java b/src/test/java/com/google/firebase/auth/TestOnlyImplFirebaseAuthTrampolines.java
deleted file mode 100644
index fb66500..0000000
--- a/src/test/java/com/google/firebase/auth/TestOnlyImplFirebaseAuthTrampolines.java
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * Copyright 2017 Google Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.firebase.auth;
-
-import com.google.api.client.json.JsonFactory;
-
-import java.io.IOException;
-
-/**
- * Provides trampolines into package-private Auth APIs used by components of Firebase
- *
- * <p>This class will not be compiled into the shipping library and can only be used in tests.
- */
-public final class TestOnlyImplFirebaseAuthTrampolines {
-
-  private TestOnlyImplFirebaseAuthTrampolines() {}
-
-  /* FirebaseApp */
-  public static FirebaseToken.FirebaseTokenImpl getToken(FirebaseToken tokenHolder) {
-    return tokenHolder.getToken();
-  }
-
-  /* FirebaseToken */
-  public static FirebaseToken parseToken(JsonFactory jsonFactory, String tokenString)
-      throws IOException {
-    return FirebaseToken.parse(jsonFactory, tokenString);
-  }
-
-}
diff --git a/src/test/java/com/google/firebase/auth/TestTokenFactory.java b/src/test/java/com/google/firebase/auth/TestTokenFactory.java
new file mode 100644
index 0000000..1f774ee
--- /dev/null
+++ b/src/test/java/com/google/firebase/auth/TestTokenFactory.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright  2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.auth;
+
+import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.json.JsonFactory;
+import com.google.api.client.json.webtoken.JsonWebSignature;
+import com.google.api.client.json.webtoken.JsonWebToken;
+import com.google.api.client.testing.http.FixedClock;
+import com.google.api.client.util.Clock;
+import com.google.common.io.BaseEncoding;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.spec.KeySpec;
+import java.security.spec.PKCS8EncodedKeySpec;
+
+class TestTokenFactory {
+
+  public static final JsonFactory JSON_FACTORY = Utils.getDefaultJsonFactory();
+  public static final Clock CLOCK = new FixedClock(2002000L * 1000);
+  public static final String PROJECT_ID = "proj-test-101";
+  public static final String PRIVATE_KEY_ID = "aaaaaaaaaabbbbbbbbbbccccccccccdddddddddd";
+  public static final String UID = "someUid";
+
+
+  private final PrivateKey privateKey;
+  private final String issuer;
+
+  TestTokenFactory(String privateKey, String issuer) throws GeneralSecurityException {
+    byte[] privateBytes = BaseEncoding.base64().decode(privateKey);
+    KeySpec spec = new PKCS8EncodedKeySpec(privateBytes);
+    this.privateKey = KeyFactory.getInstance("RSA").generatePrivate(spec);
+    this.issuer = issuer;
+  }
+
+  public String createToken() {
+    return createToken(createTokenPayload());
+  }
+
+  public String createToken(JsonWebSignature.Header header) {
+    return createToken(header, createTokenPayload());
+  }
+
+  public String createToken(JsonWebSignature.Payload payload) {
+    return createToken(createHeader(), payload);
+  }
+
+  public String createToken(JsonWebSignature.Header header, JsonWebToken.Payload payload) {
+    try {
+      return JsonWebSignature.signUsingRsaSha256(privateKey, JSON_FACTORY, header, payload);
+    } catch (GeneralSecurityException | IOException e) {
+      throw new RuntimeException("Failed to create test token", e);
+    }
+  }
+
+  public JsonWebSignature.Header createHeader() {
+    JsonWebSignature.Header header = new JsonWebSignature.Header();
+    header.setAlgorithm("RS256");
+    header.setType("JWT");
+    header.setKeyId(PRIVATE_KEY_ID);
+    return header;
+  }
+
+  public JsonWebToken.Payload createTokenPayload() {
+    JsonWebToken.Payload payload = new JsonWebToken.Payload();
+    payload.setIssuer(issuer);
+    payload.setAudience(PROJECT_ID);
+    payload.setIssuedAtTimeSeconds(CLOCK.currentTimeMillis() / 1000);
+    payload.setExpirationTimeSeconds(CLOCK.currentTimeMillis() / 1000 + 3600);
+    payload.setSubject(UID);
+    return payload;
+  }
+}
diff --git a/src/test/java/com/google/firebase/auth/internal/FirebaseTokenVerifierTest.java b/src/test/java/com/google/firebase/auth/internal/FirebaseTokenVerifierTest.java
deleted file mode 100644
index da92a75..0000000
--- a/src/test/java/com/google/firebase/auth/internal/FirebaseTokenVerifierTest.java
+++ /dev/null
@@ -1,476 +0,0 @@
-/*
- * Copyright 2017 Google Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.firebase.auth.internal;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
-
-import com.google.api.client.auth.openidconnect.IdToken;
-import com.google.api.client.http.LowLevelHttpRequest;
-import com.google.api.client.json.JsonFactory;
-import com.google.api.client.json.gson.GsonFactory;
-import com.google.api.client.json.webtoken.JsonWebSignature;
-import com.google.api.client.json.webtoken.JsonWebSignature.Header;
-import com.google.api.client.json.webtoken.JsonWebToken;
-import com.google.api.client.json.webtoken.JsonWebToken.Payload;
-import com.google.api.client.testing.http.FixedClock;
-import com.google.api.client.testing.http.MockHttpTransport;
-import com.google.api.client.testing.http.MockLowLevelHttpResponse;
-import com.google.common.io.BaseEncoding;
-import com.google.firebase.auth.FirebaseAuthException;
-import com.google.firebase.auth.FirebaseToken;
-import com.google.firebase.auth.TestOnlyImplFirebaseAuthTrampolines;
-import com.google.firebase.testing.ServiceAccount;
-import java.io.IOException;
-import java.security.GeneralSecurityException;
-import java.security.KeyFactory;
-import java.security.NoSuchAlgorithmException;
-import java.security.PrivateKey;
-import java.security.spec.InvalidKeySpecException;
-import java.security.spec.KeySpec;
-import java.security.spec.PKCS8EncodedKeySpec;
-import org.junit.Assert;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.ExpectedException;
-
-/** 
- * Unit tests for {@link FirebaseTokenVerifier}.
- */
-public class FirebaseTokenVerifierTest {
-
-  private static final JsonFactory FACTORY = new GsonFactory();
-  private static final FixedClock CLOCK = new FixedClock(2002000L * 1000);
-  private static final String PROJECT_ID = "proj-test-101";
-  private static final String TOKEN_ISSUER =
-      FirebaseTokenVerifier.ID_TOKEN_ISSUER_PREFIX + PROJECT_ID;
-  private static final String COOKIE_ISSUER =
-      FirebaseTokenVerifier.SESSION_COOKIE_ISSUER_PREFIX + PROJECT_ID;
-  private static final String PRIVATE_KEY_ID = "aaaaaaaaaabbbbbbbbbbccccccccccdddddddddd";
-  private static final String UID = "someUid";
-  private static final String ALGORITHM = "RS256";
-  private static final String LEGACY_CUSTOM_TOKEN =
-      "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJkIjp7"
-          + "InVpZCI6IjEiLCJhYmMiOiIwMTIzNDU2Nzg5fiFAIyQlXiYqKClfKy09YWJjZGVmZ2hpamtsbW5vcHF"
-          + "yc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWiwuLzsnW11cXDw"
-          + "-P1wie318In0sInYiOjAsImlhdCI6MTQ4MDk4Mj"
-          + "U2NH0.ZWEpoHgIPCAz8Q-cNFBS8jiqClTJ3j27yuRkQo-QxyI";
-
-  @Rule public ExpectedException thrown = ExpectedException.none();
-
-  private PrivateKey privateKey;
-  private FirebaseTokenVerifier idTokenVerifier;
-  private FirebaseTokenVerifier cookieVerifier;
-
-  private void initCrypto(String privateKey, String certificate)
-      throws NoSuchAlgorithmException, InvalidKeySpecException {
-    byte[] privateBytes = BaseEncoding.base64().decode(privateKey);
-    KeySpec spec = new PKCS8EncodedKeySpec(privateBytes);
-    String serviceAccountCertificates =
-        String.format("{\"%s\" : \"%s\"}", PRIVATE_KEY_ID, certificate);
-
-    MockHttpTransport mockTransport =
-        new MockHttpTransport.Builder()
-            .setLowLevelHttpResponse(
-                new MockLowLevelHttpResponse().setContent(serviceAccountCertificates))
-            .build();
-    KeyManagers keyManagers = KeyManagers.getDefault(mockTransport, CLOCK);
-    this.privateKey = KeyFactory.getInstance("RSA").generatePrivate(spec);
-    this.idTokenVerifier = FirebaseTokenVerifier.createIdTokenVerifier(
-        PROJECT_ID, keyManagers, CLOCK);
-    this.cookieVerifier = FirebaseTokenVerifier.createSessionCookieVerifier(
-        PROJECT_ID, keyManagers, CLOCK);
-  }
-
-  @Before
-  public void setUp() throws Exception {
-    initCrypto(ServiceAccount.EDITOR.getPrivateKey(), ServiceAccount.EDITOR.getCert());
-  }
-
-  private JsonWebSignature.Header createHeader() {
-    JsonWebSignature.Header header = new JsonWebSignature.Header();
-    header.setAlgorithm(ALGORITHM);
-    header.setType("JWT");
-    header.setKeyId(PRIVATE_KEY_ID);
-    return header;
-  }
-
-  private JsonWebToken.Payload createTokenPayload() {
-    JsonWebToken.Payload payload = new JsonWebToken.Payload();
-    payload.setIssuer(TOKEN_ISSUER);
-    payload.setAudience(PROJECT_ID);
-    payload.setIssuedAtTimeSeconds(CLOCK.currentTimeMillis() / 1000);
-    payload.setExpirationTimeSeconds(CLOCK.currentTimeMillis() / 1000 + 3600);
-    payload.setSubject(UID);
-    return payload;
-  }
-
-  private JsonWebToken.Payload createCookiePayload() {
-    JsonWebToken.Payload payload = new JsonWebToken.Payload();
-    payload.setIssuer(COOKIE_ISSUER);
-    payload.setAudience(PROJECT_ID);
-    payload.setIssuedAtTimeSeconds(CLOCK.currentTimeMillis() / 1000);
-    payload.setExpirationTimeSeconds(CLOCK.currentTimeMillis() / 1000 + 3600);
-    payload.setSubject(UID);
-    return payload;
-  }
-
-  private String createToken(JsonWebSignature.Header header, JsonWebToken.Payload payload)
-      throws GeneralSecurityException, IOException {
-    return JsonWebSignature.signUsingRsaSha256(privateKey, FACTORY, header, payload);
-  }
-
-  @Test
-  public void verifyToken() throws Exception {
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), createTokenPayload()));
-
-    IdToken.Payload payload = (IdToken.Payload) token.getClaims();
-    assertTrue(payload.getAudienceAsList().contains(PROJECT_ID));
-    assertEquals(TOKEN_ISSUER, payload.getIssuer());
-    idTokenVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-
-    token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), createCookiePayload()));
-    thrown.expectMessage("Firebase ID token has incorrect \"iss\" (issuer) claim.");
-    idTokenVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyCookie() throws Exception {
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), createCookiePayload()));
-
-    IdToken.Payload payload = (IdToken.Payload) token.getClaims();
-    assertTrue(payload.getAudienceAsList().contains(PROJECT_ID));
-    assertEquals(COOKIE_ISSUER, payload.getIssuer());
-    cookieVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-
-    token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), createTokenPayload()));
-    thrown.expectMessage("Firebase session cookie has incorrect \"iss\" (issuer) claim.");
-    cookieVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyTokenFailure_MissingKeyId() throws Exception {
-    Header header = createHeader();
-    header.setKeyId(null);
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(header, createTokenPayload()));
-    thrown.expectMessage("Firebase ID token has no \"kid\" claim.");
-    idTokenVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyCookieFailure_MissingKeyId() throws Exception {
-    Header header = createHeader();
-    header.setKeyId(null);
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(header, createCookiePayload()));
-    thrown.expectMessage("Firebase session cookie has no \"kid\" claim.");
-    cookieVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyTokenFailure_MissingKeyId_CustomToken() throws Exception {
-    Header header = createHeader();
-    header.setKeyId(null);
-    Payload payload = createTokenPayload();
-    payload.setAudience(
-        "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit"
-            + ".v1.IdentityToolkit");
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(FACTORY, createToken(header, payload));
-    thrown.expectMessage("verifyIdToken() expects an ID token, but was given a custom token.");
-    idTokenVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyCookieFailure_MissingKeyId_CustomToken() throws Exception {
-    Header header = createHeader();
-    header.setKeyId(null);
-    Payload payload = createCookiePayload();
-    payload.setAudience(
-        "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit"
-            + ".v1.IdentityToolkit");
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(FACTORY, createToken(header, payload));
-    thrown.expectMessage(
-        "verifySessionCookie() expects a session cookie, but was given a custom token.");
-    cookieVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyTokenFailure_IncorrectAlgorithm() throws Exception {
-    Header header = createHeader();
-    header.setAlgorithm("HS256");
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(header, createTokenPayload()));
-    thrown.expectMessage("Firebase ID token has incorrect algorithm.");
-    idTokenVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyCookieFailure_IncorrectAlgorithm() throws Exception {
-    Header header = createHeader();
-    header.setAlgorithm("HS256");
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(header, createCookiePayload()));
-    thrown.expectMessage("Firebase session cookie has incorrect algorithm.");
-    cookieVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyTokenFailure_IncorrectAudience() throws Exception {
-    Payload payload = createTokenPayload();
-    payload.setAudience(
-        "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1."
-            + "IdentityToolkit");
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), payload));
-    thrown.expectMessage("Firebase ID token has incorrect \"aud\" (audience) claim.");
-    idTokenVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyCookieFailure_IncorrectAudience() throws Exception {
-    Payload payload = createCookiePayload();
-    payload.setAudience(
-        "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1."
-            + "IdentityToolkit");
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), payload));
-    thrown.expectMessage("Firebase session cookie has incorrect \"aud\" (audience) claim.");
-    cookieVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyTokenFailure_IncorrectIssuer() throws Exception {
-    Payload payload = createTokenPayload();
-    payload.setIssuer("https://foobar.google.com/" + PROJECT_ID);
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), payload));
-    thrown.expectMessage("Firebase ID token has incorrect \"iss\" (issuer) claim.");
-    idTokenVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyCookieFailure_IncorrectIssuer() throws Exception {
-    Payload payload = createCookiePayload();
-    payload.setIssuer("https://foobar.google.com/" + PROJECT_ID);
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), payload));
-    thrown.expectMessage("Firebase session cookie has incorrect \"iss\" (issuer) claim.");
-    cookieVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyTokenFailure_MissingSubject() throws Exception {
-    Payload payload = createTokenPayload();
-    payload.setSubject(null);
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), payload));
-    thrown.expectMessage("Firebase ID token has no \"sub\" (subject) claim.");
-    idTokenVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyCookieFailure_MissingSubject() throws Exception {
-    Payload payload = createCookiePayload();
-    payload.setSubject(null);
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), payload));
-    thrown.expectMessage("Firebase session cookie has no \"sub\" (subject) claim.");
-    cookieVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyTokenFailure_EmptySubject() throws Exception {
-    Payload payload = createTokenPayload();
-    payload.setSubject("");
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), payload));
-    thrown.expectMessage("Firebase ID token has an empty string \"sub\" (subject) claim.");
-    idTokenVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyCookieFailure_EmptySubject() throws Exception {
-    Payload payload = createCookiePayload();
-    payload.setSubject("");
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), payload));
-    thrown.expectMessage("Firebase session cookie has an empty string \"sub\" (subject) claim.");
-    cookieVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyTokenFailure_LongSubject() throws Exception {
-    Payload payload = createTokenPayload();
-    payload.setSubject(
-        "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuv"
-            + "wxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz");
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), payload));
-    thrown.expectMessage(
-        "Firebase ID token has \"sub\" (subject) claim longer than 128 characters.");
-    idTokenVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyCookieFailure_LongSubject() throws Exception {
-    Payload payload = createCookiePayload();
-    payload.setSubject(
-        "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuv"
-            + "wxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz");
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), payload));
-    thrown.expectMessage(
-        "Firebase session cookie has \"sub\" (subject) claim longer than 128 characters.");
-    cookieVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyTokenFailure_NotYetIssued() throws Exception {
-    Payload payload = createTokenPayload();
-    payload.setIssuedAtTimeSeconds(System.currentTimeMillis() / 1000);
-    payload.setExpirationTimeSeconds(System.currentTimeMillis() / 1000 + 3600);
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), payload));
-    thrown.expectMessage("Firebase ID token has expired or is not yet valid.");
-    idTokenVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyCookieFailure_NotYetIssued() throws Exception {
-    Payload payload = createCookiePayload();
-    payload.setIssuedAtTimeSeconds(System.currentTimeMillis() / 1000);
-    payload.setExpirationTimeSeconds(System.currentTimeMillis() / 1000 + 3600);
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), payload));
-    thrown.expectMessage("Firebase session cookie has expired or is not yet valid.");
-    cookieVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyTokenFailure_Expired() throws Exception {
-    Payload payload = createTokenPayload();
-    payload.setIssuedAtTimeSeconds(0L);
-    payload.setExpirationTimeSeconds(3600L);
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), payload));
-    thrown.expectMessage("Firebase ID token has expired or is not yet valid.");
-    idTokenVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyCookieFailure_Expired() throws Exception {
-    Payload payload = createCookiePayload();
-    payload.setIssuedAtTimeSeconds(0L);
-    payload.setExpirationTimeSeconds(3600L);
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), payload));
-    thrown.expectMessage("Firebase session cookie has expired or is not yet valid.");
-    cookieVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyTokenFailure_WrongCert() throws Exception {
-    initCrypto(ServiceAccount.OWNER.getPrivateKey(), ServiceAccount.NONE.getCert());
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), createTokenPayload()));
-    thrown.expectMessage("Firebase ID token isn't signed by a valid public key.");
-    idTokenVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-
-    token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), createCookiePayload()));
-    thrown.expectMessage("Firebase session cookie isn't signed by a valid public key.");
-    cookieVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-
-  @Test
-  public void verifyTokenCertificateError() throws Exception {
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), createTokenPayload()));
-
-    MockHttpTransport mockTransport = new MockHttpTransport() {
-      @Override
-      public LowLevelHttpRequest buildRequest(String method, String url) throws IOException {
-        throw new IOException("Expected error");
-      }
-    };
-    KeyManagers keyManagers = KeyManagers.getDefault(mockTransport, CLOCK);
-    FirebaseTokenVerifier verifier = FirebaseTokenVerifier.createIdTokenVerifier(
-        PROJECT_ID, keyManagers, CLOCK);
-    try {
-      verifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-      Assert.fail("No exception thrown");
-    } catch (FirebaseAuthException expected) {
-      assertTrue(expected.getCause() instanceof IOException);
-      assertEquals("Expected error", expected.getCause().getMessage());
-    }
-
-    token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(
-            FACTORY, createToken(createHeader(), createCookiePayload()));
-    verifier = FirebaseTokenVerifier.createSessionCookieVerifier(PROJECT_ID, keyManagers, CLOCK);
-    try {
-      verifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-      Assert.fail("No exception thrown");
-    } catch (FirebaseAuthException expected) {
-      assertTrue(expected.getCause() instanceof IOException);
-      assertEquals("Expected error", expected.getCause().getMessage());
-    }
-  }
-
-  @Test
-  public void legacyCustomToken() throws Exception {
-    initCrypto(ServiceAccount.OWNER.getPrivateKey(), ServiceAccount.NONE.getCert());
-    FirebaseToken token =
-        TestOnlyImplFirebaseAuthTrampolines.parseToken(FACTORY, LEGACY_CUSTOM_TOKEN);
-    thrown.expectMessage(
-        "verifyIdToken() expects an ID token, but was given a legacy custom token.");
-    idTokenVerifier.verifyTokenAndSignature(TestOnlyImplFirebaseAuthTrampolines.getToken(token));
-  }
-}
diff --git a/src/test/java/com/google/firebase/internal/CountingLowLevelHttpRequest.java b/src/test/java/com/google/firebase/internal/CountingLowLevelHttpRequest.java
new file mode 100644
index 0000000..f2a4b9f
--- /dev/null
+++ b/src/test/java/com/google/firebase/internal/CountingLowLevelHttpRequest.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright  2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.internal;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.http.LowLevelHttpResponse;
+import com.google.api.client.testing.http.MockLowLevelHttpRequest;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import java.io.IOException;
+import java.util.Map;
+
+class CountingLowLevelHttpRequest extends MockLowLevelHttpRequest {
+
+  private final LowLevelHttpResponse response;
+  private final IOException exception;
+  private int count;
+
+  private CountingLowLevelHttpRequest(LowLevelHttpResponse response, IOException exception) {
+    this.response = response;
+    this.exception = exception;
+  }
+
+  static CountingLowLevelHttpRequest fromStatus(int status) {
+    return fromStatus(status, null);
+  }
+
+  static CountingLowLevelHttpRequest fromStatus(int status, Map<String, String> headers) {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setStatusCode(status)
+        .setZeroContent();
+    if (headers != null) {
+      for (Map.Entry<String, String> entry : headers.entrySet()) {
+        response.addHeader(entry.getKey(), entry.getValue());
+      }
+    }
+    return fromStatus(response);
+  }
+
+  static CountingLowLevelHttpRequest fromStatus(LowLevelHttpResponse response) {
+    return new CountingLowLevelHttpRequest(checkNotNull(response), null);
+  }
+
+  static CountingLowLevelHttpRequest fromException(IOException exception) {
+    return new CountingLowLevelHttpRequest(null, checkNotNull(exception));
+  }
+
+  @Override
+  public LowLevelHttpResponse execute() throws IOException {
+    count++;
+    if (response != null) {
+      return response;
+    }
+    throw exception;
+  }
+
+  int getCount() {
+    return count;
+  }
+}
diff --git a/src/test/java/com/google/firebase/internal/DateUtilsTest.java b/src/test/java/com/google/firebase/internal/DateUtilsTest.java
new file mode 100644
index 0000000..7c2dfd0
--- /dev/null
+++ b/src/test/java/com/google/firebase/internal/DateUtilsTest.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.internal;
+
+import java.util.Calendar;
+import java.util.Date;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Unit tests for the {@link DateUtils}. Adapted from the tests available in the
+ * <a href="http://svn.apache.org/repos/asf/httpcomponents/httpclient/tags/4.3/httpclient/src/test/java/org/apache/http/client/utils/TestDateUtils.java">
+ * Apache HTTP client</a> library.
+ */
+public class DateUtilsTest {
+
+  @Test
+  public void testBasicDateParse() {
+    final Calendar calendar = Calendar.getInstance();
+    calendar.setTimeZone(DateUtils.GMT);
+    calendar.set(2005, Calendar.OCTOBER, 14, 0, 0, 0);
+    calendar.set(Calendar.MILLISECOND, 0);
+    final Date date1 = calendar.getTime();
+
+    Date date2 = DateUtils.parseDate("Fri, 14 Oct 2005 00:00:00 GMT");
+    Assert.assertEquals(date1, date2);
+    date2 = DateUtils.parseDate("Fri, 14 Oct 2005 00:00:00 GMT");
+    Assert.assertEquals(date1, date2);
+    date2 = DateUtils.parseDate("Fri, 14 Oct 2005 00:00:00 GMT");
+    Assert.assertEquals(date1, date2);
+  }
+
+  @Test
+  public void testInvalidInput() {
+    try {
+      DateUtils.parseDate(null);
+      Assert.fail("NullPointerException should have been thrown");
+    } catch (NullPointerException ex) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testTwoDigitYearDateParse() {
+    final Calendar calendar = Calendar.getInstance();
+    calendar.setTimeZone(DateUtils.GMT);
+    calendar.set(2005, Calendar.OCTOBER, 14, 0, 0, 0);
+    calendar.set(Calendar.MILLISECOND, 0);
+    Date date1 = calendar.getTime();
+
+    Date date2 = DateUtils.parseDate("Friday, 14-Oct-05 00:00:00 GMT");
+    Assert.assertEquals(date1, date2);
+  }
+
+  @Test
+  public void testParseQuotedDate() {
+    final Calendar calendar = Calendar.getInstance();
+    calendar.setTimeZone(DateUtils.GMT);
+    calendar.set(2005, Calendar.OCTOBER, 14, 0, 0, 0);
+    calendar.set(Calendar.MILLISECOND, 0);
+    final Date date1 = calendar.getTime();
+
+    final Date date2 = DateUtils.parseDate("'Fri, 14 Oct 2005 00:00:00 GMT'");
+    Assert.assertEquals(date1, date2);
+  }
+}
diff --git a/src/test/java/com/google/firebase/internal/FirebaseRequestInitializerTest.java b/src/test/java/com/google/firebase/internal/FirebaseRequestInitializerTest.java
index c7919eb..1f610f5 100644
--- a/src/test/java/com/google/firebase/internal/FirebaseRequestInitializerTest.java
+++ b/src/test/java/com/google/firebase/internal/FirebaseRequestInitializerTest.java
@@ -17,55 +17,136 @@
 package com.google.firebase.internal;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
 
-import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpBackOffIOExceptionHandler;
 import com.google.api.client.http.HttpRequest;
-import com.google.api.client.http.HttpRequestFactory;
-import com.google.api.client.http.HttpTransport;
-import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.http.HttpResponseException;
+import com.google.auth.http.HttpCredentialsAdapter;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.FirebaseOptions;
 import com.google.firebase.TestOnlyImplFirebaseTrampolines;
 import com.google.firebase.auth.MockGoogleCredentials;
+import com.google.firebase.testing.TestUtils;
 import org.junit.After;
 import org.junit.Test;
 
 public class FirebaseRequestInitializerTest {
 
+  private static final int MAX_RETRIES = 5;
+  private static final int CONNECT_TIMEOUT_MILLIS = 30000;
+  private static final int READ_TIMEOUT_MILLIS = 60000;
+
   @After
   public void tearDown() {
     TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
   }
 
   @Test
-  public void testDefaultTimeouts() throws Exception {
+  public void testDefaultSettings() throws Exception {
     FirebaseApp app = FirebaseApp.initializeApp(new FirebaseOptions.Builder()
         .setCredentials(new MockGoogleCredentials("token"))
         .build());
-    HttpTransport transport = new MockHttpTransport();
-    HttpRequestFactory factory = transport.createRequestFactory(
-        new FirebaseRequestInitializer(app));
-    HttpRequest request = factory.buildGetRequest(
-        new GenericUrl("https://firebase.google.com"));
+    HttpRequest request = TestUtils.createRequest();
+
+    FirebaseRequestInitializer initializer = new FirebaseRequestInitializer(app);
+    initializer.initialize(request);
+
+
     assertEquals(0, request.getConnectTimeout());
     assertEquals(0, request.getReadTimeout());
     assertEquals("Bearer token", request.getHeaders().getAuthorization());
+    assertEquals(HttpRequest.DEFAULT_NUMBER_OF_RETRIES, request.getNumberOfRetries());
+    assertNull(request.getIOExceptionHandler());
+    assertTrue(request.getUnsuccessfulResponseHandler() instanceof HttpCredentialsAdapter);
   }
 
   @Test
   public void testExplicitTimeouts() throws Exception {
     FirebaseApp app = FirebaseApp.initializeApp(new FirebaseOptions.Builder()
         .setCredentials(new MockGoogleCredentials("token"))
-        .setConnectTimeout(30000)
-        .setReadTimeout(60000)
+        .setConnectTimeout(CONNECT_TIMEOUT_MILLIS)
+        .setReadTimeout(READ_TIMEOUT_MILLIS)
         .build());
-    HttpTransport transport = new MockHttpTransport();
-    HttpRequestFactory factory = transport.createRequestFactory(
-        new FirebaseRequestInitializer(app));
-    HttpRequest request = factory.buildGetRequest(
-        new GenericUrl("https://firebase.google.com"));
-    assertEquals(30000, request.getConnectTimeout());
-    assertEquals(60000, request.getReadTimeout());
+    HttpRequest request = TestUtils.createRequest();
+
+    FirebaseRequestInitializer initializer = new FirebaseRequestInitializer(app);
+    initializer.initialize(request);
+
+    assertEquals(CONNECT_TIMEOUT_MILLIS, request.getConnectTimeout());
+    assertEquals(READ_TIMEOUT_MILLIS, request.getReadTimeout());
+    assertEquals("Bearer token", request.getHeaders().getAuthorization());
+    assertEquals(HttpRequest.DEFAULT_NUMBER_OF_RETRIES, request.getNumberOfRetries());
+    assertNull(request.getIOExceptionHandler());
+    assertTrue(request.getUnsuccessfulResponseHandler() instanceof HttpCredentialsAdapter);
+  }
+
+  @Test
+  public void testRetryConfig() throws Exception {
+    FirebaseApp app = FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("token"))
+        .build());
+    RetryConfig retryConfig = RetryConfig.builder()
+        .setMaxRetries(MAX_RETRIES)
+        .build();
+    HttpRequest request = TestUtils.createRequest();
+
+    FirebaseRequestInitializer initializer = new FirebaseRequestInitializer(app, retryConfig);
+    initializer.initialize(request);
+
+    assertEquals(0, request.getConnectTimeout());
+    assertEquals(0, request.getReadTimeout());
+    assertEquals("Bearer token", request.getHeaders().getAuthorization());
+    assertEquals(MAX_RETRIES, request.getNumberOfRetries());
+    assertNull(request.getIOExceptionHandler());
+    assertNotNull(request.getUnsuccessfulResponseHandler());
+  }
+
+  @Test
+  public void testRetryConfigWithIOExceptionHandling() throws Exception {
+    FirebaseApp app = FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("token"))
+        .build());
+    RetryConfig retryConfig = RetryConfig.builder()
+        .setMaxRetries(MAX_RETRIES)
+        .setRetryOnIOExceptions(true)
+        .build();
+    HttpRequest request = TestUtils.createRequest();
+
+    FirebaseRequestInitializer initializer = new FirebaseRequestInitializer(app, retryConfig);
+    initializer.initialize(request);
+
+    assertEquals(0, request.getConnectTimeout());
+    assertEquals(0, request.getReadTimeout());
+    assertEquals("Bearer token", request.getHeaders().getAuthorization());
+    assertEquals(MAX_RETRIES, request.getNumberOfRetries());
+    assertTrue(request.getIOExceptionHandler() instanceof HttpBackOffIOExceptionHandler);
+    assertNotNull(request.getUnsuccessfulResponseHandler());
+  }
+
+  @Test
+  public void testCredentialsRetryHandler() throws Exception {
+    FirebaseApp app = FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("token"))
+        .build());
+    RetryConfig retryConfig = RetryConfig.builder()
+        .setMaxRetries(MAX_RETRIES)
+        .build();
+    CountingLowLevelHttpRequest countingRequest = CountingLowLevelHttpRequest.fromStatus(401);
+    HttpRequest request = TestUtils.createRequest(countingRequest);
+    FirebaseRequestInitializer initializer = new FirebaseRequestInitializer(app, retryConfig);
+    initializer.initialize(request);
+    request.getHeaders().setAuthorization((String) null);
+
+    try {
+      request.execute();
+    } catch (HttpResponseException e) {
+      assertEquals(401, e.getStatusCode());
+    }
+
     assertEquals("Bearer token", request.getHeaders().getAuthorization());
+    assertEquals(MAX_RETRIES + 1, countingRequest.getCount());
   }
 }
diff --git a/src/test/java/com/google/firebase/internal/RetryConfigTest.java b/src/test/java/com/google/firebase/internal/RetryConfigTest.java
new file mode 100644
index 0000000..7fab507
--- /dev/null
+++ b/src/test/java/com/google/firebase/internal/RetryConfigTest.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.internal;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import com.google.api.client.testing.util.MockSleeper;
+import com.google.api.client.util.BackOff;
+import com.google.api.client.util.ExponentialBackOff;
+import com.google.api.client.util.Sleeper;
+import com.google.common.collect.ImmutableList;
+import java.io.IOException;
+import org.junit.Test;
+
+public class RetryConfigTest {
+
+  @Test
+  public void testEmptyBuilder() {
+    RetryConfig config = RetryConfig.builder().build();
+
+    assertTrue(config.getRetryStatusCodes().isEmpty());
+    assertEquals(0, config.getMaxRetries());
+    assertEquals(2 * 60 * 1000, config.getMaxIntervalMillis());
+    assertEquals(2.0, config.getBackOffMultiplier(), 0.01);
+    assertSame(Sleeper.DEFAULT, config.getSleeper());
+
+    ExponentialBackOff backOff = (ExponentialBackOff) config.newBackOff();
+    assertEquals(2 * 60 * 1000, backOff.getMaxIntervalMillis());
+    assertEquals(2.0, backOff.getMultiplier(), 0.01);
+    assertEquals(500, backOff.getInitialIntervalMillis());
+    assertEquals(0.0, backOff.getRandomizationFactor(), 0.01);
+    assertNotSame(backOff, config.newBackOff());
+  }
+
+  @Test
+  public void testBuilderWithAllSettings() {
+    ImmutableList<Integer> statusCodes = ImmutableList.of(500, 503);
+    Sleeper sleeper = new MockSleeper();
+    RetryConfig config = RetryConfig.builder()
+        .setMaxRetries(4)
+        .setRetryStatusCodes(statusCodes)
+        .setRetryOnIOExceptions(true)
+        .setMaxIntervalMillis(5 * 60 * 1000)
+        .setBackOffMultiplier(1.5)
+        .setSleeper(sleeper)
+        .build();
+
+    assertEquals(2, config.getRetryStatusCodes().size());
+    assertEquals(statusCodes.get(0), config.getRetryStatusCodes().get(0));
+    assertEquals(statusCodes.get(1), config.getRetryStatusCodes().get(1));
+    assertTrue(config.isRetryOnIOExceptions());
+    assertEquals(4, config.getMaxRetries());
+    assertEquals(5 * 60 * 1000, config.getMaxIntervalMillis());
+    assertEquals(1.5, config.getBackOffMultiplier(), 0.01);
+    assertSame(sleeper, config.getSleeper());
+
+    ExponentialBackOff backOff = (ExponentialBackOff) config.newBackOff();
+    assertEquals(500, backOff.getInitialIntervalMillis());
+    assertEquals(5 * 60 * 1000, backOff.getMaxIntervalMillis());
+    assertEquals(1.5, backOff.getMultiplier(), 0.01);
+    assertEquals(0.0, backOff.getRandomizationFactor(), 0.01);
+    assertNotSame(backOff, config.newBackOff());
+  }
+
+  @Test
+  public void testExponentialBackOff() throws IOException {
+    RetryConfig config = RetryConfig.builder()
+        .setMaxIntervalMillis(12000)
+        .build();
+
+    BackOff backOff = config.newBackOff();
+
+    assertEquals(500, backOff.nextBackOffMillis());
+    assertEquals(1000, backOff.nextBackOffMillis());
+    assertEquals(2000, backOff.nextBackOffMillis());
+    assertEquals(4000, backOff.nextBackOffMillis());
+    assertEquals(8000, backOff.nextBackOffMillis());
+    assertEquals(12000, backOff.nextBackOffMillis());
+    assertEquals(12000, backOff.nextBackOffMillis());
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testNegativeMaxRetriesNotAllowed() {
+    RetryConfig.builder()
+        .setMaxRetries(-1)
+        .build();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testMaxIntervalMillisTooSmall() {
+    RetryConfig.builder()
+        .setMaxIntervalMillis(499)
+        .build();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testBackOffMultiplierTooSmall() {
+    RetryConfig.builder()
+        .setBackOffMultiplier(0.99)
+        .build();
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testSleeperCannotBeNull() {
+    RetryConfig.builder()
+        .setSleeper(null)
+        .build();
+  }
+}
diff --git a/src/test/java/com/google/firebase/internal/RetryInitializerTest.java b/src/test/java/com/google/firebase/internal/RetryInitializerTest.java
new file mode 100644
index 0000000..8732e8b
--- /dev/null
+++ b/src/test/java/com/google/firebase/internal/RetryInitializerTest.java
@@ -0,0 +1,244 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.internal;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.http.HttpBackOffIOExceptionHandler;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.http.HttpUnsuccessfulResponseHandler;
+import com.google.api.client.http.LowLevelHttpResponse;
+import com.google.api.client.testing.http.MockLowLevelHttpRequest;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.api.client.testing.util.MockSleeper;
+import com.google.api.client.util.Sleeper;
+import com.google.auth.http.HttpCredentialsAdapter;
+import com.google.common.collect.ImmutableList;
+import com.google.firebase.auth.MockGoogleCredentials;
+import com.google.firebase.internal.RetryInitializer.RetryHandlerDecorator;
+import com.google.firebase.testing.TestUtils;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.Test;
+
+public class RetryInitializerTest {
+
+  private static final int MAX_RETRIES = 4;
+
+  @Test
+  public void testEnableRetry() throws IOException {
+    RetryConfig retryConfig = retryOnIOAndServiceUnavailableErrors(new MockSleeper());
+    RetryInitializer initializer = new RetryInitializer(retryConfig);
+    HttpRequest request = TestUtils.createRequest();
+
+    initializer.initialize(request);
+
+    assertEquals(MAX_RETRIES, request.getNumberOfRetries());
+    assertTrue(request.getUnsuccessfulResponseHandler() instanceof RetryHandlerDecorator);
+    RetryUnsuccessfulResponseHandler retryHandler =
+        ((RetryHandlerDecorator) request.getUnsuccessfulResponseHandler()).getRetryHandler();
+    assertSame(retryConfig, retryHandler.getRetryConfig());
+    assertTrue(request.getIOExceptionHandler() instanceof HttpBackOffIOExceptionHandler);
+  }
+
+  @Test
+  public void testRetryOnIOExceptionDisabled() throws IOException {
+    RetryInitializer initializer = new RetryInitializer(RetryConfig.builder()
+        .setMaxRetries(MAX_RETRIES)
+        .setRetryOnIOExceptions(false)
+        .setRetryStatusCodes(ImmutableList.of(503))
+        .build());
+    HttpRequest request = TestUtils.createRequest();
+
+    initializer.initialize(request);
+
+    assertEquals(MAX_RETRIES, request.getNumberOfRetries());
+    assertNotNull(request.getUnsuccessfulResponseHandler());
+    assertNull(request.getIOExceptionHandler());
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testRetryConfigCannotBeNull() {
+    new RetryInitializer(null);
+  }
+
+  @Test
+  public void testRetryOnIOException() throws IOException {
+    MockSleeper sleeper = new MockSleeper();
+    RetryInitializer initializer = new RetryInitializer(
+        retryOnIOAndServiceUnavailableErrors(sleeper));
+
+    CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromException(
+        new IOException("test error"));
+    HttpRequest request = TestUtils.createRequest(failingRequest);
+    initializer.initialize(request);
+    final HttpUnsuccessfulResponseHandler retryHandler = request.getUnsuccessfulResponseHandler();
+
+    try {
+      request.execute();
+      fail("No exception thrown for HTTP error");
+    } catch (IOException e) {
+      assertEquals("test error", e.getMessage());
+    }
+
+    assertEquals(MAX_RETRIES, sleeper.getCount());
+    assertEquals(MAX_RETRIES + 1, failingRequest.getCount());
+    assertSame(retryHandler, request.getUnsuccessfulResponseHandler());
+  }
+
+  @Test
+  public void testRetryOnHttpError() throws IOException {
+    MockSleeper sleeper = new MockSleeper();
+    RetryInitializer initializer = new RetryInitializer(
+        retryOnIOAndServiceUnavailableErrors(sleeper));
+    CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(503);
+    HttpRequest request = TestUtils.createRequest(failingRequest);
+    initializer.initialize(request);
+    final HttpUnsuccessfulResponseHandler retryHandler = request.getUnsuccessfulResponseHandler();
+
+    try {
+      request.execute();
+      fail("No exception thrown for HTTP error");
+    } catch (HttpResponseException e) {
+      assertEquals(503, e.getStatusCode());
+    }
+
+    assertEquals(MAX_RETRIES, sleeper.getCount());
+    assertEquals(MAX_RETRIES + 1, failingRequest.getCount());
+    assertSame(retryHandler, request.getUnsuccessfulResponseHandler());
+  }
+
+  @Test
+  public void testMaxRetriesCountIsCumulative() throws IOException {
+    MockSleeper sleeper = new MockSleeper();
+    RetryInitializer initializer = new RetryInitializer(
+        retryOnIOAndServiceUnavailableErrors(sleeper));
+
+    final AtomicInteger counter = new AtomicInteger(0);
+    MockLowLevelHttpRequest failingRequest = new MockLowLevelHttpRequest(){
+      @Override
+      public LowLevelHttpResponse execute() throws IOException {
+        if (counter.getAndIncrement() < 2) {
+          throw new IOException("test error");
+        } else {
+          return new MockLowLevelHttpResponse().setStatusCode(503).setZeroContent();
+        }
+      }
+    };
+    HttpRequest request = TestUtils.createRequest(failingRequest);
+    initializer.initialize(request);
+    final HttpUnsuccessfulResponseHandler retryHandler = request.getUnsuccessfulResponseHandler();
+
+    try {
+      request.execute();
+      fail("No exception thrown for HTTP error");
+    } catch (HttpResponseException e) {
+      assertEquals(503, e.getStatusCode());
+    }
+
+    assertEquals(MAX_RETRIES, sleeper.getCount());
+    assertEquals(MAX_RETRIES + 1, counter.get());
+    assertSame(retryHandler, request.getUnsuccessfulResponseHandler());
+  }
+
+  @Test
+  public void testOtherErrorHandlersCalledBeforeRetry() throws IOException {
+    final AtomicInteger otherErrorHandlerCalls = new AtomicInteger(0);
+    HttpCredentialsAdapter credentials = new HttpCredentialsAdapter(new MockGoogleCredentials()) {
+      @Override
+      public boolean handleResponse(
+          HttpRequest request, HttpResponse response, boolean supportsRetry) {
+        otherErrorHandlerCalls.incrementAndGet();
+        return super.handleResponse(request, response, supportsRetry);
+      }
+    };
+    MockSleeper sleeper = new MockSleeper();
+    RetryInitializer initializer = new RetryInitializer(RetryConfig.builder()
+        .setMaxRetries(MAX_RETRIES)
+        .setRetryStatusCodes(ImmutableList.of(503))
+        .setSleeper(sleeper)
+        .build());
+    CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(503);
+    HttpRequest request = TestUtils.createRequest(failingRequest);
+    credentials.initialize(request);
+    initializer.initialize(request);
+    final HttpUnsuccessfulResponseHandler retryHandler = request.getUnsuccessfulResponseHandler();
+
+    try {
+      request.execute();
+      fail("No exception thrown for HTTP error");
+    } catch (HttpResponseException e) {
+      assertEquals(503, e.getStatusCode());
+    }
+
+    assertEquals(MAX_RETRIES, sleeper.getCount());
+    assertEquals(MAX_RETRIES + 1, failingRequest.getCount());
+    assertEquals(MAX_RETRIES + 1, otherErrorHandlerCalls.get());
+    assertSame(retryHandler, request.getUnsuccessfulResponseHandler());
+  }
+
+  @Test
+  public void testRetryHandlerDoesNotGetOverwritten() throws IOException {
+    final AtomicInteger otherErrorHandlerCalls = new AtomicInteger(0);
+    HttpUnsuccessfulResponseHandler credentials = new HttpUnsuccessfulResponseHandler() {
+      @Override
+      public boolean handleResponse(
+          HttpRequest request, HttpResponse response, boolean supportsRetry) throws IOException {
+        otherErrorHandlerCalls.incrementAndGet();
+        request.setUnsuccessfulResponseHandler(this);
+        throw new IOException("test");
+      }
+    };
+    MockSleeper sleeper = new MockSleeper();
+    RetryInitializer initializer = new RetryInitializer(RetryConfig.builder()
+        .setMaxRetries(MAX_RETRIES)
+        .setRetryStatusCodes(ImmutableList.of(503))
+        .setSleeper(sleeper)
+        .build());
+    CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(503);
+    HttpRequest request = TestUtils.createRequest(failingRequest);
+    request.setUnsuccessfulResponseHandler(credentials);
+    initializer.initialize(request);
+    final HttpUnsuccessfulResponseHandler retryHandler = request.getUnsuccessfulResponseHandler();
+
+    try {
+      request.execute();
+      fail("No exception thrown for HTTP error");
+    } catch (Exception e) {
+      assertEquals("test", e.getMessage());
+    }
+
+    assertEquals(1, otherErrorHandlerCalls.get());
+    assertSame(retryHandler, request.getUnsuccessfulResponseHandler());
+  }
+
+  private RetryConfig retryOnIOAndServiceUnavailableErrors(Sleeper sleeper) {
+    return RetryConfig.builder()
+        .setMaxRetries(MAX_RETRIES)
+        .setRetryStatusCodes(ImmutableList.of(503))
+        .setRetryOnIOExceptions(true)
+        .setSleeper(sleeper)
+        .build();
+  }
+}
diff --git a/src/test/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandlerTest.java b/src/test/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandlerTest.java
new file mode 100644
index 0000000..077e9e5
--- /dev/null
+++ b/src/test/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandlerTest.java
@@ -0,0 +1,258 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.internal;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.testing.http.FixedClock;
+import com.google.api.client.testing.util.MockSleeper;
+import com.google.api.client.util.Clock;
+import com.google.api.client.util.Sleeper;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.primitives.Longs;
+import com.google.firebase.testing.TestUtils;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.TimeZone;
+import org.junit.Test;
+
+public class RetryUnsuccessfulResponseHandlerTest {
+
+  private static final int MAX_RETRIES = 4;
+  private static final RetryConfig.Builder TEST_RETRY_CONFIG = RetryConfig.builder()
+      .setRetryStatusCodes(ImmutableList.of(429, 503))
+      .setMaxIntervalMillis(120 * 1000);
+
+  @Test
+  public void testDoesNotRetryOnUnspecifiedHttpStatus() throws IOException {
+    MultipleCallSleeper sleeper = new MultipleCallSleeper();
+    RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+        testRetryConfig(sleeper));
+    CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(404);
+    HttpRequest request = TestUtils.createRequest(failingRequest);
+    request.setUnsuccessfulResponseHandler(handler);
+    request.setNumberOfRetries(MAX_RETRIES);
+
+    try {
+      request.execute();
+      fail("No exception thrown for HTTP error");
+    } catch (HttpResponseException e) {
+      assertEquals(404, e.getStatusCode());
+    }
+
+    assertEquals(0, sleeper.getCount());
+    assertEquals(1, failingRequest.getCount());
+  }
+
+  @Test
+  public void testRetryOnHttpClientErrorWhenSpecified() throws IOException {
+    MultipleCallSleeper sleeper = new MultipleCallSleeper();
+    RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+        testRetryConfig(sleeper));
+    CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(429);
+    HttpRequest request = TestUtils.createRequest(failingRequest);
+    request.setUnsuccessfulResponseHandler(handler);
+    request.setNumberOfRetries(MAX_RETRIES);
+
+    try {
+      request.execute();
+      fail("No exception thrown for HTTP error");
+    } catch (HttpResponseException e) {
+      assertEquals(429, e.getStatusCode());
+    }
+
+    assertEquals(MAX_RETRIES, sleeper.getCount());
+    assertArrayEquals(new long[]{500, 1000, 2000, 4000}, sleeper.getDelays());
+    assertEquals(MAX_RETRIES + 1, failingRequest.getCount());
+  }
+
+  @Test
+  public void testExponentialBackOffDoesNotExceedMaxInterval() throws IOException {
+    MultipleCallSleeper sleeper = new MultipleCallSleeper();
+    RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+        testRetryConfig(sleeper));
+    CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(503);
+    HttpRequest request = TestUtils.createRequest(failingRequest);
+    request.setUnsuccessfulResponseHandler(handler);
+    request.setNumberOfRetries(10);
+
+    try {
+      request.execute();
+      fail("No exception thrown for HTTP error");
+    } catch (HttpResponseException e) {
+      assertEquals(503, e.getStatusCode());
+    }
+
+    assertEquals(10, sleeper.getCount());
+    assertArrayEquals(
+        new long[]{500, 1000, 2000, 4000, 8000, 16000, 32000, 64000, 120000, 120000},
+        sleeper.getDelays());
+    assertEquals(11, failingRequest.getCount());
+  }
+
+  @Test
+  public void testRetryAfterGivenAsSeconds() throws IOException {
+    MultipleCallSleeper sleeper = new MultipleCallSleeper();
+    RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+        testRetryConfig(sleeper));
+    CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(
+        503, ImmutableMap.of("retry-after", "2"));
+    HttpRequest request = TestUtils.createRequest(failingRequest);
+    request.setUnsuccessfulResponseHandler(handler);
+    request.setNumberOfRetries(MAX_RETRIES);
+
+    try {
+      request.execute();
+      fail("No exception thrown for HTTP error");
+    } catch (HttpResponseException e) {
+      assertEquals(503, e.getStatusCode());
+    }
+
+    assertEquals(MAX_RETRIES, sleeper.getCount());
+    assertArrayEquals(new long[]{2000, 2000, 2000, 2000}, sleeper.getDelays());
+    assertEquals(MAX_RETRIES + 1, failingRequest.getCount());
+  }
+
+  @Test
+  public void testRetryAfterGivenAsDate() throws IOException {
+    SimpleDateFormat dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz");
+    dateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
+    Date date = new Date(1000);
+    Clock clock = new FixedClock(date.getTime());
+    String retryAfter = dateFormat.format(new Date(date.getTime() + 30000));
+
+    MultipleCallSleeper sleeper = new MultipleCallSleeper();
+    RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+        testRetryConfig(sleeper), clock);
+    CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(
+        503, ImmutableMap.of("retry-after", retryAfter));
+    HttpRequest request = TestUtils.createRequest(failingRequest);
+    request.setUnsuccessfulResponseHandler(handler);
+    request.setNumberOfRetries(4);
+
+    try {
+      request.execute();
+      fail("No exception thrown for HTTP error");
+    } catch (HttpResponseException e) {
+      assertEquals(503, e.getStatusCode());
+    }
+
+    assertEquals(4, sleeper.getCount());
+    assertArrayEquals(new long[]{30000, 30000, 30000, 30000}, sleeper.getDelays());
+    assertEquals(5, failingRequest.getCount());
+  }
+
+  @Test
+  public void testInvalidRetryAfterFailsOverToExpBackOff() throws IOException {
+    MultipleCallSleeper sleeper = new MultipleCallSleeper();
+    RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+        testRetryConfig(sleeper));
+    CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(
+        503, ImmutableMap.of("retry-after", "not valid"));
+    HttpRequest request = TestUtils.createRequest(failingRequest);
+    request.setUnsuccessfulResponseHandler(handler);
+    request.setNumberOfRetries(4);
+
+    try {
+      request.execute();
+      fail("No exception thrown for HTTP error");
+    } catch (HttpResponseException e) {
+      assertEquals(503, e.getStatusCode());
+    }
+
+    assertEquals(4, sleeper.getCount());
+    assertArrayEquals(new long[]{500, 1000, 2000, 4000}, sleeper.getDelays());
+    assertEquals(5, failingRequest.getCount());
+  }
+
+  @Test
+  public void testDoesNotRetryWhenRetryAfterIsTooLong() throws IOException {
+    MultipleCallSleeper sleeper = new MultipleCallSleeper();
+    RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+        testRetryConfig(sleeper));
+    CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(
+        503, ImmutableMap.of("retry-after", "121"));
+    HttpRequest request = TestUtils.createRequest(failingRequest);
+    request.setUnsuccessfulResponseHandler(handler);
+    request.setNumberOfRetries(MAX_RETRIES);
+
+    try {
+      request.execute();
+      fail("No exception thrown for HTTP error");
+    } catch (HttpResponseException e) {
+      assertEquals(503, e.getStatusCode());
+    }
+
+    assertEquals(0, sleeper.getCount());
+    assertEquals(1, failingRequest.getCount());
+  }
+
+  @Test
+  public void testDoesNotRetryAfterInterruption() throws IOException {
+    MockSleeper sleeper = new MockSleeper() {
+      @Override
+      public void sleep(long millis) throws InterruptedException {
+        super.sleep(millis);
+        throw new InterruptedException();
+      }
+    };
+    RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+        testRetryConfig(sleeper));
+    CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(503);
+    HttpRequest request = TestUtils.createRequest(failingRequest);
+    request.setUnsuccessfulResponseHandler(handler);
+    request.setNumberOfRetries(MAX_RETRIES);
+
+    try {
+      request.execute();
+      fail("No exception thrown for HTTP error");
+    } catch (HttpResponseException e) {
+      assertEquals(503, e.getStatusCode());
+    }
+
+    assertEquals(1, sleeper.getCount());
+    assertEquals(1, failingRequest.getCount());
+  }
+
+  private RetryConfig testRetryConfig(Sleeper sleeper) {
+    return TEST_RETRY_CONFIG.setSleeper(sleeper).build();
+  }
+
+
+  private static class MultipleCallSleeper extends MockSleeper {
+
+    private final List<Long> delays = new ArrayList<>();
+
+    @Override
+    public void sleep(long millis) throws InterruptedException {
+      super.sleep(millis);
+      delays.add(millis);
+    }
+
+    long[] getDelays() {
+      return Longs.toArray(delays);
+    }
+  }
+}
diff --git a/src/test/java/com/google/firebase/testing/TestUtils.java b/src/test/java/com/google/firebase/testing/TestUtils.java
index 0dec4db..3e33a52 100644
--- a/src/test/java/com/google/firebase/testing/TestUtils.java
+++ b/src/test/java/com/google/firebase/testing/TestUtils.java
@@ -19,8 +19,14 @@ package com.google.firebase.testing;
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.api.client.googleapis.testing.auth.oauth2.MockTokenServerTransport;
+import com.google.api.client.http.EmptyContent;
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestFactory;
 import com.google.api.client.http.HttpTransport;
 import com.google.api.client.json.webtoken.JsonWebSignature;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpRequest;
 import com.google.auth.http.HttpTransportFactory;
 import com.google.auth.oauth2.GoogleCredentials;
 import com.google.common.collect.ImmutableMap;
@@ -41,7 +47,8 @@ import java.util.Map;
 public class TestUtils {
 
   public static final long TEST_TIMEOUT_MILLIS = 7 * 1000;
-  public static final String TEST_ADC_ACCESS_TOKEN = "test-adc-access-token";
+  private static final String TEST_ADC_ACCESS_TOKEN = "test-adc-access-token";
+  private static final GenericUrl TEST_URL = new GenericUrl("https://firebase.google.com");
 
   private static GoogleCredentials defaultCredentials;
 
@@ -123,4 +130,16 @@ public class TestUtils {
     });
     return defaultCredentials;
   }
+
+  public static HttpRequest createRequest() throws IOException {
+    return createRequest(new MockLowLevelHttpRequest());
+  }
+
+  public static HttpRequest createRequest(MockLowLevelHttpRequest request) throws IOException {
+    HttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpRequest(request)
+        .build();
+    HttpRequestFactory requestFactory = transport.createRequestFactory();
+    return requestFactory.buildPostRequest(TEST_URL, new EmptyContent());
+  }
 }