Repository: firebase/firebase-admin-java
Pull request #365 (2020-02-12 20:14:45)

feat(auth): Add bulk get/delete methods
This PR allows callers to retrieve a list of users by unique identifier (uid, email, phone, federated provider uid) as well as to delete a list of users.

RELEASE NOTE: Added getUsers() and deleteUsers() APIs for retrieving and deleting user accounts in bulk.


Patch:
diff --git a/pom.xml b/pom.xml
index af25b87..b71b4e7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -59,7 +59,7 @@
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
         <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
         <skipUTs>${skipTests}</skipUTs>
-        <netty.version>4.1.34.Final</netty.version>
+        <netty.version>4.1.45.Final</netty.version>
     </properties>
 
     <scm>
diff --git a/src/main/java/com/google/firebase/auth/DeleteUsersResult.java b/src/main/java/com/google/firebase/auth/DeleteUsersResult.java
new file mode 100644
index 0000000..e8ca7db
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/DeleteUsersResult.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2020 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 com.google.common.collect.ImmutableList;
+import com.google.firebase.auth.internal.BatchDeleteResponse;
+import com.google.firebase.internal.NonNull;
+import java.util.List;
+
+/**
+ * Represents the result of the {@link FirebaseAuth#deleteUsersAsync(List)} API.
+ */
+public final class DeleteUsersResult {
+
+  private final int successCount;
+  private final List<ErrorInfo> errors;
+
+  DeleteUsersResult(int users, BatchDeleteResponse response) {
+    ImmutableList.Builder<ErrorInfo> errorsBuilder = ImmutableList.builder();
+    List<BatchDeleteResponse.ErrorInfo> responseErrors = response.getErrors();
+    if (responseErrors != null) {
+      checkArgument(users >= responseErrors.size());
+      for (BatchDeleteResponse.ErrorInfo error : responseErrors) {
+        errorsBuilder.add(new ErrorInfo(error.getIndex(), error.getMessage()));
+      }
+    }
+    errors = errorsBuilder.build();
+    successCount = users - errors.size();
+  }
+
+  /**
+   * Returns the number of users that were deleted successfully (possibly zero). Users that did not
+   * exist prior to calling {@link FirebaseAuth#deleteUsersAsync(List)} are considered to be
+   * successfully deleted.
+   */ 
+  public int getSuccessCount() {
+    return successCount;
+  }
+
+  /**
+   * Returns the number of users that failed to be deleted (possibly zero).
+   */
+  public int getFailureCount() {
+    return errors.size();
+  }
+
+  /**
+   * A list of {@link ErrorInfo} instances describing the errors that were encountered during
+   * the deletion. Length of this list is equal to the return value of 
+   * {@link #getFailureCount()}.
+   *
+   * @return A non-null list (possibly empty).
+   */
+  @NonNull
+  public List<ErrorInfo> getErrors() {
+    return errors;
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/EmailIdentifier.java b/src/main/java/com/google/firebase/auth/EmailIdentifier.java
new file mode 100644
index 0000000..8e729c2
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/EmailIdentifier.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2020 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.firebase.auth.internal.GetAccountInfoRequest;
+import com.google.firebase.internal.NonNull;
+
+/**
+ * Used for looking up an account by email.
+ *
+ * @see {FirebaseAuth#getUsers}
+ */
+public final class EmailIdentifier extends UserIdentifier {
+  private final String email;
+
+  public EmailIdentifier(@NonNull String email) {
+    UserRecord.checkEmail(email);
+    this.email = email;
+  }
+
+  @Override
+  public String toString() {
+    return "EmailIdentifier(" + email + ")";
+  }
+
+  @Override
+  void populate(@NonNull GetAccountInfoRequest payload) {
+    payload.addEmail(email);
+  }
+
+  @Override
+  boolean matches(@NonNull UserRecord userRecord) {
+    return email.equals(userRecord.getEmail());
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuth.java b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
index f7f6231..923778a 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseAuth.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
@@ -42,8 +42,11 @@ import com.google.firebase.internal.NonNull;
 import com.google.firebase.internal.Nullable;
 
 import java.io.IOException;
+import java.util.Collection;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
@@ -601,7 +604,82 @@ public class FirebaseAuth {
   }
 
   /**
-   * Gets a page of users starting from the specified {@code pageToken}. Page size will be
+   * Gets the user data corresponding to the specified identifiers.
+   *
+   * <p>There are no ordering guarantees; in particular, the nth entry in the users result list is
+   * not guaranteed to correspond to the nth entry in the input parameters list.
+   *
+   * <p>A maximum of 100 identifiers may be specified. If more than 100 identifiers are
+   * supplied, this method throws an {@link IllegalArgumentException}.
+   *
+   * @param identifiers The identifiers used to indicate which user records should be returned. Must
+   *     have 100 or fewer entries.
+   * @return The corresponding user records.
+   * @throws IllegalArgumentException If any of the identifiers are invalid or if more than 100
+   *     identifiers are specified.
+   * @throws NullPointerException If the identifiers parameter is null.
+   * @throws FirebaseAuthException If an error occurs while retrieving user data.
+   */
+  public GetUsersResult getUsers(@NonNull Collection<UserIdentifier> identifiers)
+      throws FirebaseAuthException {
+    return getUsersOp(identifiers).call();
+  }
+
+  /**
+   * Gets the user data corresponding to the specified identifiers.
+   *
+   * <p>There are no ordering guarantees; in particular, the nth entry in the users result list is
+   * not guaranteed to correspond to the nth entry in the input parameters list.
+   *
+   * <p>A maximum of 100 identifiers may be specified. If more than 100 identifiers are
+   * supplied, this method throws an {@link IllegalArgumentException}.
+   *
+   * @param identifiers The identifiers used to indicate which user records should be returned.
+   *     Must have 100 or fewer entries.
+   * @return An {@code ApiFuture} that resolves to the corresponding user records.
+   * @throws IllegalArgumentException If any of the identifiers are invalid or if more than 100
+   *     identifiers are specified.
+   * @throws NullPointerException If the identifiers parameter is null.
+   */
+  public ApiFuture<GetUsersResult> getUsersAsync(@NonNull Collection<UserIdentifier> identifiers) {
+    return getUsersOp(identifiers).callAsync(firebaseApp);
+  }
+
+  private CallableOperation<GetUsersResult, FirebaseAuthException> getUsersOp(
+      @NonNull final Collection<UserIdentifier> identifiers) {
+    checkNotDestroyed();
+    checkNotNull(identifiers, "identifiers must not be null");
+    checkArgument(identifiers.size() <= FirebaseUserManager.MAX_GET_ACCOUNTS_BATCH_SIZE,
+        "identifiers parameter must have <= " + FirebaseUserManager.MAX_GET_ACCOUNTS_BATCH_SIZE
+        + " entries.");
+
+    final FirebaseUserManager userManager = getUserManager();
+    return new CallableOperation<GetUsersResult, FirebaseAuthException>() {
+      @Override
+      protected GetUsersResult execute() throws FirebaseAuthException {
+        Set<UserRecord> users = userManager.getAccountInfo(identifiers);
+        Set<UserIdentifier> notFound = new HashSet<>();
+        for (UserIdentifier id : identifiers) {
+          if (!isUserFound(id, users)) {
+            notFound.add(id);
+          }
+        }
+        return new GetUsersResult(users, notFound);
+      }
+    };
+  }
+
+  private boolean isUserFound(UserIdentifier id, Collection<UserRecord> userRecords) {
+    for (UserRecord userRecord : userRecords) {
+      if (id.matches(userRecord)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Gets a page of users starting from the specified {@code pageToken}. Page size is
    * limited to 1000 users.
    *
    * @param pageToken A non-empty page token string, or null to retrieve the first page of users.
@@ -842,8 +920,67 @@ public class FirebaseAuth {
   }
 
   /**
-   * Imports the provided list of users into Firebase Auth. At most 1000 users can be imported at a
-   * time. This operation is optimized for bulk imports and will ignore checks on identifier
+   * Deletes the users specified by the given identifiers.
+   *
+   * <p>Deleting a non-existing user does not generate an error (the method is idempotent).
+   * Non-existing users are considered to be successfully deleted and are therefore included in the
+   * DeleteUsersResult.getSuccessCount() value.
+   *
+   * <p>A maximum of 1000 identifiers may be supplied. If more than 1000 identifiers are
+   * supplied, this method throws an {@link IllegalArgumentException}.
+   *
+   * <p>This API has a rate limit of 1 QPS. Exceeding the limit may result in a quota exceeded
+   * error. If you want to delete more than 1000 users, we suggest adding a delay to ensure you
+   * don't exceed this limit.
+   *
+   * @param uids The uids of the users to be deleted. Must have <= 1000 entries.
+   * @return The total number of successful/failed deletions, as well as the array of errors that
+   *     correspond to the failed deletions.
+   * @throw IllegalArgumentException If any of the identifiers are invalid or if more than 1000
+   *     identifiers are specified.
+   * @throws FirebaseAuthException If an error occurs while deleting users.
+   */
+  public DeleteUsersResult deleteUsers(List<String> uids) throws FirebaseAuthException {
+    return deleteUsersOp(uids).call();
+  }
+
+  /**
+   * Similar to {@link #deleteUsers(List)} but performs the operation asynchronously.
+   *
+   * @param uids The uids of the users to be deleted. Must have <= 1000 entries.
+   * @return An {@code ApiFuture} that resolves to the total number of successful/failed
+   *     deletions, as well as the array of errors that correspond to the failed deletions. If an
+   *     error occurs while deleting the user account, the future throws a
+   *     {@link FirebaseAuthException}.
+   * @throw IllegalArgumentException If any of the identifiers are invalid or if more than 1000
+   *     identifiers are specified.
+   */
+  public ApiFuture<DeleteUsersResult> deleteUsersAsync(List<String> uids) {
+    return deleteUsersOp(uids).callAsync(firebaseApp);
+  }
+
+  private CallableOperation<DeleteUsersResult, FirebaseAuthException> deleteUsersOp(
+      final List<String> uids) {
+    checkNotDestroyed();
+    checkNotNull(uids, "uids must not be null");
+    for (String uid : uids) {
+      UserRecord.checkUid(uid);
+    }
+    checkArgument(uids.size() <= FirebaseUserManager.MAX_DELETE_ACCOUNTS_BATCH_SIZE,
+        "uids parameter must have <= " + FirebaseUserManager.MAX_DELETE_ACCOUNTS_BATCH_SIZE
+        + " entries.");
+    final FirebaseUserManager userManager = getUserManager();
+    return new CallableOperation<DeleteUsersResult, FirebaseAuthException>() {
+      @Override
+      protected DeleteUsersResult execute() throws FirebaseAuthException {
+        return userManager.deleteUsers(uids);
+      }
+    };
+  }
+
+  /**
+   * Imports the provided list of users into Firebase Auth. You can import a maximum of 1000 users
+   * at a time.  This operation is optimized for bulk imports and does not check identifier
    * uniqueness which could result in duplications.
    *
    * <p>{@link UserImportOptions} is required to import users with passwords. See
diff --git a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
index 03c2813..ab8759c 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
@@ -39,9 +39,10 @@ import com.google.firebase.FirebaseApp;
 import com.google.firebase.ImplFirebaseTrampolines;
 import com.google.firebase.auth.UserRecord.CreateRequest;
 import com.google.firebase.auth.UserRecord.UpdateRequest;
+import com.google.firebase.auth.internal.BatchDeleteResponse;
 import com.google.firebase.auth.internal.DownloadAccountResponse;
+import com.google.firebase.auth.internal.GetAccountInfoRequest;
 import com.google.firebase.auth.internal.GetAccountInfoResponse;
-
 import com.google.firebase.auth.internal.HttpErrorResponse;
 import com.google.firebase.auth.internal.UploadAccountResponse;
 import com.google.firebase.internal.ApiClientUtils;
@@ -50,8 +51,11 @@ import com.google.firebase.internal.Nullable;
 import com.google.firebase.internal.SdkUtils;
 
 import java.io.IOException;
+import java.util.Collection;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * FirebaseUserManager provides methods for interacting with the Google Identity Toolkit via its
@@ -86,6 +90,8 @@ class FirebaseUserManager {
       .put("INVALID_DYNAMIC_LINK_DOMAIN", "invalid-dynamic-link-domain")
       .build();
 
+  static final int MAX_GET_ACCOUNTS_BATCH_SIZE = 100;
+  static final int MAX_DELETE_ACCOUNTS_BATCH_SIZE = 1000;
   static final int MAX_LIST_USERS_RESULTS = 1000;
   static final int MAX_IMPORT_USERS = 1000;
 
@@ -171,6 +177,33 @@ class FirebaseUserManager {
     return new UserRecord(response.getUsers().get(0), jsonFactory);
   }
 
+  Set<UserRecord> getAccountInfo(@NonNull Collection<UserIdentifier> identifiers)
+      throws FirebaseAuthException {
+    if (identifiers.isEmpty()) {
+      return new HashSet<UserRecord>();
+    }
+
+    GetAccountInfoRequest payload = new GetAccountInfoRequest();
+    for (UserIdentifier id : identifiers) {
+      id.populate(payload);
+    }
+
+    GetAccountInfoResponse response = post(
+        "/accounts:lookup", payload, GetAccountInfoResponse.class);
+
+    if (response == null) {
+      throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to parse server response");
+    }
+
+    Set<UserRecord> results = new HashSet<>();
+    if (response.getUsers() != null) {
+      for (GetAccountInfoResponse.User user : response.getUsers()) {
+        results.add(new UserRecord(user, jsonFactory));
+      }
+    }
+    return results;
+  }
+
   String createUser(CreateRequest request) throws FirebaseAuthException {
     GenericJson response = post(
         "/accounts", request.getProperties(), GenericJson.class);
@@ -200,6 +233,23 @@ class FirebaseUserManager {
     }
   }
 
+  /**
+   * @pre uids != null
+   * @pre uids.size() <= MAX_DELETE_ACCOUNTS_BATCH_SIZE
+   */
+  DeleteUsersResult deleteUsers(@NonNull List<String> uids) throws FirebaseAuthException {
+    final Map<String, Object> payload = ImmutableMap.<String, Object>of(
+        "localIds", uids,
+        "force", true);
+    BatchDeleteResponse response = post(
+        "/accounts:batchDelete", payload, BatchDeleteResponse.class);
+    if (response == null) {
+      throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to delete users");
+    }
+
+    return new DeleteUsersResult(uids.size(), response);
+  }
+
   DownloadAccountResponse listUsers(int maxResults, String pageToken) throws FirebaseAuthException {
     ImmutableMap.Builder<String, Object> builder = ImmutableMap.<String, Object>builder()
         .put("maxResults", maxResults);
diff --git a/src/main/java/com/google/firebase/auth/GetUsersResult.java b/src/main/java/com/google/firebase/auth/GetUsersResult.java
new file mode 100644
index 0000000..3ceec01
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/GetUsersResult.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2020 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.checkNotNull;
+
+import com.google.firebase.internal.NonNull;
+import java.util.Set;
+
+/**
+ * Represents the result of the {@link FirebaseAuth#getUsersAsync(Collection)} API.
+ */
+public final class GetUsersResult {
+  private final Set<UserRecord> users;
+  private final Set<UserIdentifier> notFound;
+
+  GetUsersResult(@NonNull Set<UserRecord> users, @NonNull Set<UserIdentifier> notFound) {
+    this.users = checkNotNull(users);
+    this.notFound = checkNotNull(notFound);
+  }
+
+  /**
+   * Set of user records corresponding to the set of users that were requested. Only users
+   * that were found are listed here. The result set is unordered.
+   */
+  @NonNull
+  public Set<UserRecord> getUsers() {
+    return this.users;
+  }
+
+  /**
+   * Set of identifiers that were requested, but not found.
+   */
+  @NonNull
+  public Set<UserIdentifier> getNotFound() {
+    return this.notFound;
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/PhoneIdentifier.java b/src/main/java/com/google/firebase/auth/PhoneIdentifier.java
new file mode 100644
index 0000000..bdc84fe
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/PhoneIdentifier.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2020 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.firebase.auth.internal.GetAccountInfoRequest;
+import com.google.firebase.internal.NonNull;
+
+/**
+ * Used for looking up an account by phone number.
+ *
+ * @see {FirebaseAuth#getUsers}
+ */
+public final class PhoneIdentifier extends UserIdentifier {
+  private final String phoneNumber;
+
+  public PhoneIdentifier(@NonNull String phoneNumber) {
+    UserRecord.checkPhoneNumber(phoneNumber);
+    this.phoneNumber = phoneNumber;
+  }
+
+  @Override
+  public String toString() {
+    return "PhoneIdentifier(" + phoneNumber + ")";
+  }
+
+  @Override
+  void populate(@NonNull GetAccountInfoRequest payload) {
+    payload.addPhoneNumber(phoneNumber);
+  }
+
+  @Override
+  boolean matches(@NonNull UserRecord userRecord) {
+    return phoneNumber.equals(userRecord.getPhoneNumber());
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/ProviderIdentifier.java b/src/main/java/com/google/firebase/auth/ProviderIdentifier.java
new file mode 100644
index 0000000..25e0002
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/ProviderIdentifier.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2020 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.firebase.auth.internal.GetAccountInfoRequest;
+import com.google.firebase.internal.NonNull;
+
+/**
+ * Used for looking up an account by provider.
+ *
+ * @see {FirebaseAuth#getUsers}
+ */
+public final class ProviderIdentifier extends UserIdentifier {
+  private final String providerId;
+  private final String providerUid;
+
+  public ProviderIdentifier(@NonNull String providerId, @NonNull String providerUid) {
+    UserRecord.checkProvider(providerId, providerUid);
+    this.providerId = providerId;
+    this.providerUid = providerUid;
+  }
+
+  @Override
+  public String toString() {
+    return "ProviderIdentifier(" + providerId + ", " + providerUid + ")";
+  }
+
+  @Override
+  void populate(@NonNull GetAccountInfoRequest payload) {
+    payload.addFederatedUserId(providerId, providerUid);
+  }
+
+  @Override
+  boolean matches(@NonNull UserRecord userRecord) {
+    for (UserInfo userInfo : userRecord.getProviderData()) {
+      if (providerId.equals(userInfo.getProviderId()) && providerUid.equals(userInfo.getUid())) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/UidIdentifier.java b/src/main/java/com/google/firebase/auth/UidIdentifier.java
new file mode 100644
index 0000000..a4f7069
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/UidIdentifier.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2020 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.firebase.auth.internal.GetAccountInfoRequest;
+import com.google.firebase.internal.NonNull;
+
+/**
+ * Used for looking up an account by uid.
+ *
+ * @see {FirebaseAuth#getUsers}
+ */
+public final class UidIdentifier extends UserIdentifier {
+  private final String uid;
+
+  public UidIdentifier(@NonNull String uid) {
+    UserRecord.checkUid(uid);
+    this.uid = uid;
+  }
+
+  @Override
+  public String toString() {
+    return "UidIdentifier(" + uid + ")";
+  }
+
+  @Override
+  void populate(@NonNull GetAccountInfoRequest payload) {
+    payload.addUid(uid);
+  }
+
+  @Override
+  boolean matches(@NonNull UserRecord userRecord) {
+    return uid.equals(userRecord.getUid());
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/UserIdentifier.java b/src/main/java/com/google/firebase/auth/UserIdentifier.java
new file mode 100644
index 0000000..7ec9699
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/UserIdentifier.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2020 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.firebase.auth.internal.GetAccountInfoRequest;
+import com.google.firebase.internal.NonNull;
+
+/**
+ * Identifies a user to be looked up.
+ */
+public abstract class UserIdentifier {
+  public abstract String toString();
+
+  abstract void populate(@NonNull GetAccountInfoRequest payload);
+
+  abstract boolean matches(@NonNull UserRecord userRecord);
+}
diff --git a/src/main/java/com/google/firebase/auth/UserMetadata.java b/src/main/java/com/google/firebase/auth/UserMetadata.java
index a287237..85a24a0 100644
--- a/src/main/java/com/google/firebase/auth/UserMetadata.java
+++ b/src/main/java/com/google/firebase/auth/UserMetadata.java
@@ -23,14 +23,16 @@ public class UserMetadata {
 
   private final long creationTimestamp;
   private final long lastSignInTimestamp;
+  private final long lastRefreshTimestamp;
 
   public UserMetadata(long creationTimestamp) {
-    this(creationTimestamp, 0L);
+    this(creationTimestamp, 0L, 0L);
   }
 
-  public UserMetadata(long creationTimestamp, long lastSignInTimestamp) {
+  public UserMetadata(long creationTimestamp, long lastSignInTimestamp, long lastRefreshTimestamp) {
     this.creationTimestamp = creationTimestamp;
     this.lastSignInTimestamp = lastSignInTimestamp;
+    this.lastRefreshTimestamp = lastRefreshTimestamp;
   }
 
   /**
@@ -50,4 +52,13 @@ public class UserMetadata {
   public long getLastSignInTimestamp() {
     return lastSignInTimestamp;
   }
+
+  /**
+   * Returns the time at which the user was last active (ID token refreshed).
+   * 
+   * @return Milliseconds since epoch timestamp, or 0 if the user was never active.
+   */
+  public long getLastRefreshTimestamp() {
+    return lastRefreshTimestamp;
+  }
 }
diff --git a/src/main/java/com/google/firebase/auth/UserRecord.java b/src/main/java/com/google/firebase/auth/UserRecord.java
index e004500..64e7c27 100644
--- a/src/main/java/com/google/firebase/auth/UserRecord.java
+++ b/src/main/java/com/google/firebase/auth/UserRecord.java
@@ -20,6 +20,7 @@ import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.api.client.json.JsonFactory;
+import com.google.api.client.util.DateTime;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -80,7 +81,15 @@ public class UserRecord implements UserInfo {
       }
     }
     this.tokensValidAfterTimestamp = response.getValidSince() * 1000;
-    this.userMetadata = new UserMetadata(response.getCreatedAt(), response.getLastLoginAt());
+
+    String lastRefreshAtRfc3339 = response.getLastRefreshAt();
+    long lastRefreshAtMillis = 0;
+    if (!Strings.isNullOrEmpty(lastRefreshAtRfc3339)) {
+      lastRefreshAtMillis = DateTime.parseRfc3339(lastRefreshAtRfc3339).getValue();
+    }
+
+    this.userMetadata = new UserMetadata(
+        response.getCreatedAt(), response.getLastLoginAt(), lastRefreshAtMillis);
     this.customClaims = parseCustomClaims(response.getCustomClaims(), jsonFactory);
   }
 
@@ -247,6 +256,11 @@ public class UserRecord implements UserInfo {
         "phone number must be a valid, E.164 compliant identifier starting with a '+' sign");
   }
 
+  static void checkProvider(String providerId, String providerUid) {
+    checkArgument(!Strings.isNullOrEmpty(providerId), "providerId must be a non-empty string");
+    checkArgument(!Strings.isNullOrEmpty(providerUid), "providerUid must be a non-empty string");
+  }
+
   static void checkUrl(String photoUrl) {
     checkArgument(!Strings.isNullOrEmpty(photoUrl), "url cannot be null or empty");
     try {
diff --git a/src/main/java/com/google/firebase/auth/internal/BatchDeleteResponse.java b/src/main/java/com/google/firebase/auth/internal/BatchDeleteResponse.java
new file mode 100644
index 0000000..728cf63
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/internal/BatchDeleteResponse.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2020 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 com.google.api.client.util.Key;
+import java.util.List;
+
+/**
+ * Represents the response from Google identity Toolkit for a batch delete request.
+ */
+public class BatchDeleteResponse {
+
+  @Key("errors")
+  private List<ErrorInfo> errors;
+
+  public List<ErrorInfo> getErrors() {
+    return errors;
+  }
+
+  public static class ErrorInfo {
+    @Key("index")
+    private int index;
+
+    @Key("message")
+    private String message;
+
+    // A 'localId' field also exists here, but is not currently exposed in the Admin SDK.
+
+    public int getIndex() {
+      return index;
+    }
+
+    public String getMessage() {
+      return message;
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/internal/GetAccountInfoRequest.java b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoRequest.java
new file mode 100644
index 0000000..67c4d0e
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoRequest.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2020 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 com.google.api.client.util.Key;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents the request to look up account information.
+ */
+public final class GetAccountInfoRequest {
+
+  @Key("localId")
+  private List<String> uids = null;
+
+  @Key("email")
+  private List<String> emails = null;
+
+  @Key("phoneNumber")
+  private List<String> phoneNumbers = null;
+
+  @Key("federatedUserId")
+  private List<FederatedUserId> federatedUserIds = null;
+
+  private static final class FederatedUserId {
+    @Key("providerId")
+    private String providerId = null;
+
+    @Key("rawId")
+    private String rawId = null;
+
+    FederatedUserId(String providerId, String rawId) {
+      this.providerId = providerId;
+      this.rawId = rawId;
+    }
+  }
+
+  public void addUid(String uid) {
+    if (uids == null) {
+      uids = new ArrayList<>();
+    }
+    uids.add(uid);
+  }
+
+  public void addEmail(String email) {
+    if (emails == null) {
+      emails = new ArrayList<>();
+    }
+    emails.add(email);
+  }
+
+  public void addPhoneNumber(String phoneNumber) {
+    if (phoneNumbers == null) {
+      phoneNumbers = new ArrayList<>();
+    }
+    phoneNumbers.add(phoneNumber);
+  }
+
+  public void addFederatedUserId(String providerId, String providerUid) {
+    if (federatedUserIds == null) {
+      federatedUserIds = new ArrayList<>();
+    }
+    federatedUserIds.add(new FederatedUserId(providerId, providerUid));
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java
index 3d17c50..e843358 100644
--- a/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java
+++ b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java
@@ -73,6 +73,9 @@ public final class GetAccountInfoResponse {
     @Key("lastLoginAt")
     private long lastLoginAt;
 
+    @Key("lastRefreshAt")
+    private String lastRefreshAt;
+
     @Key("validSince")
     private long validSince;
 
@@ -119,6 +122,10 @@ public final class GetAccountInfoResponse {
       return lastLoginAt;
     }
 
+    public String getLastRefreshAt() {
+      return lastRefreshAt;
+    }
+
     public long getValidSince() {
       return validSince;
     }


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 6a8361d..aa45f51 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
@@ -137,6 +137,78 @@ public class FirebaseAuthIT {
     }
   }
 
+  @Test
+  public void testDeleteUsers() throws Exception {
+    UserRecord user1 = newUserWithParams();
+    UserRecord user2 = newUserWithParams();
+    UserRecord user3 = newUserWithParams();
+
+    DeleteUsersResult deleteUsersResult =
+        slowDeleteUsersAsync(ImmutableList.of(user1.getUid(), user2.getUid(), user3.getUid()))
+            .get();
+
+    assertEquals(3, deleteUsersResult.getSuccessCount());
+    assertEquals(0, deleteUsersResult.getFailureCount());
+    assertTrue(deleteUsersResult.getErrors().isEmpty());
+
+    GetUsersResult getUsersResult =
+        auth.getUsersAsync(
+                ImmutableList.<UserIdentifier>of(new UidIdentifier(user1.getUid()),
+                    new UidIdentifier(user2.getUid()), new UidIdentifier(user3.getUid())))
+            .get();
+
+    assertTrue(getUsersResult.getUsers().isEmpty());
+    assertEquals(3, getUsersResult.getNotFound().size());
+  }
+
+  @Test
+  public void testDeleteExistingAndNonExistingUsers() throws Exception {
+    UserRecord user1 = newUserWithParams();
+
+    DeleteUsersResult deleteUsersResult =
+        slowDeleteUsersAsync(ImmutableList.of(user1.getUid(), "uid-that-doesnt-exist")).get();
+
+    assertEquals(2, deleteUsersResult.getSuccessCount());
+    assertEquals(0, deleteUsersResult.getFailureCount());
+    assertTrue(deleteUsersResult.getErrors().isEmpty());
+
+    GetUsersResult getUsersResult =
+        auth.getUsersAsync(ImmutableList.<UserIdentifier>of(new UidIdentifier(user1.getUid()),
+                               new UidIdentifier("uid-that-doesnt-exist")))
+            .get();
+
+    assertTrue(getUsersResult.getUsers().isEmpty());
+    assertEquals(2, getUsersResult.getNotFound().size());
+  }
+
+  @Test
+  public void testDeleteUsersIsIdempotent() throws Exception {
+    UserRecord user1 = newUserWithParams();
+
+    DeleteUsersResult result = slowDeleteUsersAsync(ImmutableList.of(user1.getUid())).get();
+
+    assertEquals(1, result.getSuccessCount());
+    assertEquals(0, result.getFailureCount());
+    assertTrue(result.getErrors().isEmpty());
+
+    // Delete the user again to ensure that everything still counts as a success.
+    result = slowDeleteUsersAsync(ImmutableList.of(user1.getUid())).get();
+
+    assertEquals(1, result.getSuccessCount());
+    assertEquals(0, result.getFailureCount());
+    assertTrue(result.getErrors().isEmpty());
+  }
+
+  /**
+   * The {@code batchDelete} endpoint has a rate limit of 1 QPS. Use this test
+   * helper to ensure you don't exceed the quota.
+   */
+  // TODO(rsgowman): When/if the rate limit is relaxed, eliminate this helper.
+  private ApiFuture<DeleteUsersResult> slowDeleteUsersAsync(List<String> uids) throws Exception {
+    TimeUnit.SECONDS.sleep(1);
+    return auth.deleteUsersAsync(uids);
+  }
+
   @Test
   public void testCreateUserWithParams() throws Exception {
     RandomUser randomUser = RandomUser.create();
@@ -248,6 +320,35 @@ public class FirebaseAuthIT {
     }
   }
 
+  @Test
+  public void testLastRefreshTime() throws Exception {
+    RandomUser user = RandomUser.create();
+    UserRecord newUserRecord = auth.createUser(new CreateRequest()
+                                                   .setUid(user.uid)
+                                                   .setEmail(user.email)
+                                                   .setEmailVerified(false)
+                                                   .setPassword("password"));
+
+    try {
+      // New users should not have a lastRefreshTimestamp set.
+      assertEquals(0, newUserRecord.getUserMetadata().getLastRefreshTimestamp());
+
+      // Login to cause the lastRefreshTimestamp to be set.
+      signInWithPassword(newUserRecord.getEmail(), "password");
+
+      UserRecord userRecord = auth.getUser(newUserRecord.getUid());
+
+      // Ensure the lastRefreshTimestamp is approximately "now" (with a tollerance of 10 minutes).
+      long now = System.currentTimeMillis();
+      long tollerance = TimeUnit.MINUTES.toMillis(10);
+      long lastRefreshTimestamp = userRecord.getUserMetadata().getLastRefreshTimestamp();
+      assertTrue(now - tollerance <= lastRefreshTimestamp);
+      assertTrue(lastRefreshTimestamp <= now + tollerance);
+    } finally {
+      auth.deleteUser(newUserRecord.getUid());
+    }
+  }
+
   @Test
   public void testListUsers() throws Exception {
     final List<String> uids = new ArrayList<>();
@@ -607,7 +708,7 @@ public class FirebaseAuthIT {
     return result;
   }
 
-  private String randomPhoneNumber() {
+  static String randomPhoneNumber() {
     Random random = new Random();
     StringBuilder builder = new StringBuilder("+1");
     for (int i = 0; i < 10; i++) {
@@ -637,7 +738,7 @@ public class FirebaseAuthIT {
     GenericUrl url = new GenericUrl(VERIFY_PASSWORD_URL + "?key="
         + IntegrationTestUtils.getApiKey());
     Map<String, Object> content = ImmutableMap.<String, Object>of(
-        "email", email, "password", password);
+        "email", email, "password", password, "returnSecureToken", true);
     HttpRequest request = transport.createRequestFactory().buildPostRequest(url,
         new JsonHttpContent(jsonFactory, content));
     request.setParser(new JsonObjectParser(jsonFactory));
@@ -696,9 +797,9 @@ public class FirebaseAuthIT {
     }
   }
 
-  private static class RandomUser {
-    private final String uid;
-    private final String email;
+  static class RandomUser {
+    final String uid;
+    final String email;
 
     private RandomUser(String uid, String email) {
       this.uid = uid;
@@ -712,4 +813,21 @@ public class FirebaseAuthIT {
       return new RandomUser(uid, email);
     }
   }
+
+  static UserRecord newUserWithParams() throws Exception {
+    return newUserWithParams(auth);
+  }
+
+  static UserRecord newUserWithParams(FirebaseAuth auth) throws Exception {
+    // TODO(rsgowman): This function could be used throughout this file (similar to the other
+    // ports).
+    RandomUser randomUser = RandomUser.create();
+    return auth.createUser(new CreateRequest()
+                               .setUid(randomUser.uid)
+                               .setEmail(randomUser.email)
+                               .setPhoneNumber(randomPhoneNumber())
+                               .setDisplayName("Random User")
+                               .setPhotoUrl("https://example.com/photo.png")
+                               .setPassword("password"));
+  }
 }
diff --git a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
index de0b7fa..67d0448 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
@@ -33,6 +33,7 @@ import com.google.api.client.json.JsonFactory;
 import com.google.api.client.testing.http.MockHttpTransport;
 import com.google.api.client.testing.http.MockLowLevelHttpResponse;
 import com.google.auth.oauth2.GoogleCredentials;
+import com.google.common.base.Strings;
 import com.google.common.base.Supplier;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -41,6 +42,8 @@ import com.google.firebase.FirebaseApp;
 import com.google.firebase.FirebaseOptions;
 import com.google.firebase.TestOnlyImplFirebaseTrampolines;
 import com.google.firebase.auth.FirebaseUserManager.EmailLinkType;
+import com.google.firebase.auth.UidIdentifier;
+import com.google.firebase.auth.UserIdentifier;
 import com.google.firebase.auth.UserRecord.CreateRequest;
 import com.google.firebase.auth.UserRecord.UpdateRequest;
 import com.google.firebase.internal.SdkUtils;
@@ -52,6 +55,8 @@ import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.math.BigDecimal;
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 
@@ -167,6 +172,153 @@ public class FirebaseUserManagerTest {
     }
   }
 
+  @Test
+  public void testGetUsersExceeds100() throws Exception {
+    FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+            .setCredentials(credentials)
+            .build());
+    List<UserIdentifier> identifiers = new ArrayList<>();
+    for (int i = 0; i < 101; i++) {
+      identifiers.add(new UidIdentifier("uid_" + i));
+    }
+
+    try {
+      FirebaseAuth.getInstance().getUsers(identifiers);
+      fail("No error thrown for too many supplied identifiers");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testGetUsersNull() throws Exception {
+    FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+            .setCredentials(credentials)
+            .build());
+    try {
+      FirebaseAuth.getInstance().getUsers(null);
+      fail("No error thrown for null identifiers");
+    } catch (NullPointerException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testGetUsersEmpty() throws Exception {
+    initializeAppForUserManagement();
+    GetUsersResult result = FirebaseAuth.getInstance().getUsers(new ArrayList<UserIdentifier>());
+    assertTrue(result.getUsers().isEmpty());
+    assertTrue(result.getNotFound().isEmpty());
+  }
+
+  @Test
+  public void testGetUsersAllNonExisting() throws Exception {
+    initializeAppForUserManagement("{ \"users\": [] }");
+    List<UserIdentifier> ids = ImmutableList.<UserIdentifier>of(
+        new UidIdentifier("id-that-doesnt-exist"));
+    GetUsersResult result = FirebaseAuth.getInstance().getUsers(ids);
+    assertTrue(result.getUsers().isEmpty());
+    assertEquals(ids.size(), result.getNotFound().size());
+    assertTrue(result.getNotFound().containsAll(ids));
+  }
+
+  @Test
+  public void testGetUsersMultipleIdentifierTypes() throws Exception {
+    initializeAppForUserManagement((""
+        + "{ "
+        + "    'users': [{ "
+        + "        'localId': 'uid1', "
+        + "        'email': 'user1@example.com', "
+        + "        'phoneNumber': '+15555550001' "
+        + "    }, { "
+        + "        'localId': 'uid2', "
+        + "        'email': 'user2@example.com', "
+        + "        'phoneNumber': '+15555550002' "
+        + "    }, { "
+        + "        'localId': 'uid3', "
+        + "        'email': 'user3@example.com', "
+        + "        'phoneNumber': '+15555550003' "
+        + "    }, { "
+        + "        'localId': 'uid4', "
+        + "        'email': 'user4@example.com', "
+        + "        'phoneNumber': '+15555550004', "
+        + "        'providerUserInfo': [{ "
+        + "            'providerId': 'google.com', "
+        + "            'rawId': 'google_uid4' "
+        + "        }] "
+        + "    }] "
+        + "} "
+        ).replace("'", "\""));
+
+    UidIdentifier doesntExist = new UidIdentifier("this-uid-doesnt-exist");
+    List<UserIdentifier> ids = ImmutableList.<UserIdentifier>of(
+        new UidIdentifier("uid1"),
+        new EmailIdentifier("user2@example.com"),
+        new PhoneIdentifier("+15555550003"),
+        new ProviderIdentifier("google.com", "google_uid4"),
+        doesntExist);
+    GetUsersResult result = FirebaseAuth.getInstance().getUsers(ids);
+    Collection<String> uids = userRecordsToUids(result.getUsers());
+    assertTrue(uids.containsAll(ImmutableList.of("uid1", "uid2", "uid3", "uid4")));
+    assertEquals(1, result.getNotFound().size());
+    assertTrue(result.getNotFound().contains(doesntExist));
+  }
+
+  private Collection<String> userRecordsToUids(Collection<UserRecord> userRecords) {
+    Collection<String> uids = new HashSet<>();
+    for (UserRecord userRecord : userRecords) {
+      uids.add(userRecord.getUid());
+    }
+    return uids;
+  }
+
+  @Test
+  public void testInvalidUidIdentifier() throws Exception {
+    try {
+      new UidIdentifier("too long " + Strings.repeat(".", 128));
+      fail("No error thrown for invalid uid");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testInvalidEmailIdentifier() throws Exception {
+    try {
+      new EmailIdentifier("invalid email addr");
+      fail("No error thrown for invalid email");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testInvalidPhoneIdentifier() throws Exception {
+    try {
+      new PhoneIdentifier("invalid phone number");
+      fail("No error thrown for invalid phone number");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testInvalidProviderIdentifier() throws Exception {
+    try {
+      new ProviderIdentifier("", "valid-uid");
+      fail("No error thrown for invalid provider id");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+
+    try {
+      new ProviderIdentifier("valid-id", "");
+      fail("No error thrown for invalid provider uid");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
   @Test
   public void testListUsers() throws Exception {
     final TestResponseInterceptor interceptor = initializeAppForUserManagement(
@@ -278,6 +430,81 @@ public class FirebaseUserManagerTest {
     checkRequestHeaders(interceptor);
   }
 
+  @Test
+  public void testDeleteUsersExceeds1000() throws Exception {
+    FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+            .setCredentials(credentials)
+            .build());
+    List<String> ids = new ArrayList<>();
+    for (int i = 0; i < 1001; i++) {
+      ids.add("id" + i);
+    }
+    try {
+      FirebaseAuth.getInstance().deleteUsersAsync(ids);
+      fail("No error thrown for too many uids");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testDeleteUsersInvalidId() throws Exception {
+    FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+            .setCredentials(credentials)
+            .build());
+    try {
+      FirebaseAuth.getInstance().deleteUsersAsync(
+          ImmutableList.of("too long " + Strings.repeat(".", 128)));
+      fail("No error thrown for too long uid");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testDeleteUsersIndexesErrorsCorrectly() throws Exception {
+    initializeAppForUserManagement((""
+        + "{ "
+        + "    'errors': [{ "
+        + "        'index': 0, "
+        + "        'localId': 'uid1', "
+        + "        'message': 'NOT_DISABLED : Disable the account before batch deletion.' "
+        + "    }, { "
+        + "        'index': 2, "
+        + "        'localId': 'uid3', "
+        + "        'message': 'something awful' "
+        + "    }] "
+        + "} "
+        ).replace("'", "\""));
+
+    DeleteUsersResult result = FirebaseAuth.getInstance().deleteUsersAsync(ImmutableList.of(
+          "uid1", "uid2", "uid3", "uid4"
+          )).get();
+
+    assertEquals(2, result.getSuccessCount());
+    assertEquals(2, result.getFailureCount());
+    assertEquals(2, result.getErrors().size());
+    assertEquals(0, result.getErrors().get(0).getIndex());
+    assertEquals(
+        "NOT_DISABLED : Disable the account before batch deletion.",
+        result.getErrors().get(0).getReason());
+    assertEquals(2, result.getErrors().get(1).getIndex());
+    assertEquals("something awful", result.getErrors().get(1).getReason());
+  }
+
+  @Test
+  public void testDeleteUsersSuccess() throws Exception {
+    initializeAppForUserManagement("{}");
+
+    DeleteUsersResult result = FirebaseAuth.getInstance().deleteUsersAsync(ImmutableList.of(
+          "uid1", "uid2", "uid3"
+          )).get();
+
+    assertEquals(3, result.getSuccessCount());
+    assertEquals(0, result.getFailureCount());
+    assertTrue(result.getErrors().isEmpty());
+  }
+
   @Test
   public void testImportUsers() throws Exception {
     TestResponseInterceptor interceptor = initializeAppForUserManagement("{}");
diff --git a/src/test/java/com/google/firebase/auth/GetUsersIT.java b/src/test/java/com/google/firebase/auth/GetUsersIT.java
new file mode 100644
index 0000000..efe2f78
--- /dev/null
+++ b/src/test/java/com/google/firebase/auth/GetUsersIT.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2020 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.common.collect.ImmutableList;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.testing.IntegrationTestUtils;
+import java.util.Collection;
+
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class GetUsersIT {
+  private static FirebaseAuth auth;
+  private static UserRecord testUser1;
+  private static UserRecord testUser2;
+  private static UserRecord testUser3;
+  private static String importUserUid;
+
+  @BeforeClass
+  public static void setUpClass() throws Exception {
+    FirebaseApp masterApp = IntegrationTestUtils.ensureDefaultApp();
+    auth = FirebaseAuth.getInstance(masterApp);
+
+    testUser1 = FirebaseAuthIT.newUserWithParams(auth);
+    testUser2 = FirebaseAuthIT.newUserWithParams(auth);
+    testUser3 = FirebaseAuthIT.newUserWithParams(auth);
+
+    FirebaseAuthIT.RandomUser randomUser = FirebaseAuthIT.RandomUser.create();
+    importUserUid = randomUser.uid;
+    String phone = FirebaseAuthIT.randomPhoneNumber();
+    UserImportResult result = auth.importUsers(ImmutableList.of(
+          ImportUserRecord.builder()
+          .setUid(randomUser.uid)
+          .setEmail(randomUser.email)
+          .setPhoneNumber(phone)
+          .addUserProvider(
+              UserProvider.builder()
+                  .setProviderId("google.com")
+                  .setUid("google_" + randomUser.uid)
+                  .build())
+          .build()
+          ));
+    assertEquals(1, result.getSuccessCount());
+    assertEquals(0, result.getFailureCount());
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    // TODO(rsgowman): deleteUsers (plural) would make more sense here, but it's currently rate
+    // limited to 1qps. When/if that's relaxed, change this to just delete them all at once.
+    auth.deleteUser(testUser1.getUid());
+    auth.deleteUser(testUser2.getUid());
+    auth.deleteUser(testUser3.getUid());
+    auth.deleteUser(importUserUid);
+  }
+
+  @Test
+  public void testVariousIdentifiers() throws Exception {
+    GetUsersResult result = auth.getUsersAsync(ImmutableList.<UserIdentifier>of(
+          new UidIdentifier(testUser1.getUid()),
+          new EmailIdentifier(testUser2.getEmail()),
+          new PhoneIdentifier(testUser3.getPhoneNumber()),
+          new ProviderIdentifier("google.com", "google_" + importUserUid)
+          )).get();
+
+    Collection<String> expectedUids = ImmutableList.of(
+        testUser1.getUid(), testUser2.getUid(), testUser3.getUid(), importUserUid);
+
+    assertTrue(sameUsers(result.getUsers(), expectedUids));
+    assertEquals(0, result.getNotFound().size());
+  }
+
+  @Test
+  public void testIgnoresNonExistingUsers() throws Exception {
+    UidIdentifier doesntExistId = new UidIdentifier("uid_that_doesnt_exist");
+    GetUsersResult result = auth.getUsersAsync(ImmutableList.<UserIdentifier>of(
+          new UidIdentifier(testUser1.getUid()),
+          doesntExistId,
+          new UidIdentifier(testUser3.getUid())
+          )).get();
+
+    Collection<String> expectedUids = ImmutableList.of(testUser1.getUid(), testUser3.getUid());
+
+    assertTrue(sameUsers(result.getUsers(), expectedUids));
+    assertEquals(1, result.getNotFound().size());
+    assertTrue(result.getNotFound().contains(doesntExistId));
+  }
+
+  @Test
+  public void testOnlyNonExistingUsers() throws Exception {
+    UidIdentifier doesntExistId = new UidIdentifier("uid_that_doesnt_exist");
+    GetUsersResult result = auth.getUsersAsync(ImmutableList.<UserIdentifier>of(
+          doesntExistId
+          )).get();
+
+    assertEquals(0, result.getUsers().size());
+    assertEquals(1, result.getNotFound().size());
+    assertTrue(result.getNotFound().contains(doesntExistId));
+  }
+
+  @Test
+  public void testDedupsDuplicateUsers() throws Exception {
+    GetUsersResult result = auth.getUsersAsync(ImmutableList.<UserIdentifier>of(
+          new UidIdentifier(testUser1.getUid()),
+          new UidIdentifier(testUser1.getUid())
+          )).get();
+
+    Collection<String> expectedUids = ImmutableList.of(testUser1.getUid());
+
+    assertEquals(1, result.getUsers().size());
+    assertTrue(sameUsers(result.getUsers(), expectedUids));
+    assertEquals(0, result.getNotFound().size());
+  }
+
+  /**
+   * Checks to see if the userRecords collection contains the given uids.
+   *
+   * <p>Behaviour is undefined if there are duplicate entries in either of the parameters.
+   */
+  private boolean sameUsers(Collection<UserRecord> userRecords, Collection<String> uids) {
+    if (userRecords.size() != uids.size()) {
+      return false;
+    }
+
+    for (UserRecord userRecord : userRecords) {
+      if (!uids.contains(userRecord.getUid())) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+}
diff --git a/src/test/java/com/google/firebase/auth/ImportUserRecordTest.java b/src/test/java/com/google/firebase/auth/ImportUserRecordTest.java
index 011a5cc..e2ae36c 100644
--- a/src/test/java/com/google/firebase/auth/ImportUserRecordTest.java
+++ b/src/test/java/com/google/firebase/auth/ImportUserRecordTest.java
@@ -62,7 +62,7 @@ public class ImportUserRecordTest {
         .setDisplayName("Test User")
         .setPhotoUrl("https://test.com/user.png")
         .setPhoneNumber("+1234567890")
-        .setUserMetadata(new UserMetadata(date.getTime(), date.getTime()))
+        .setUserMetadata(new UserMetadata(date.getTime(), date.getTime(), date.getTime()))
         .setDisabled(false)
         .setEmailVerified(true)
         .setPasswordHash("password".getBytes())