Repository: firebase/firebase-admin-java
Pull request #105 (2017-12-05 01:15:56)

Upgraded the version of Google API common


Patch:
diff --git a/pom.xml b/pom.xml
index cf7bf68..1fabd3a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -396,7 +396,7 @@
         <dependency>
             <groupId>com.google.api</groupId>
             <artifactId>api-common</artifactId>
-            <version>1.1.0</version>
+            <version>1.2.0</version>
         </dependency>
         <dependency>
             <groupId>com.google.auth</groupId>
diff --git a/src/main/java/com/google/firebase/auth/ExportedUserRecord.java b/src/main/java/com/google/firebase/auth/ExportedUserRecord.java
new file mode 100644
index 0000000..3cf385c
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/ExportedUserRecord.java
@@ -0,0 +1,66 @@
+/*
+ * 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.firebase.auth.internal.DownloadAccountResponse.User;
+import com.google.firebase.internal.Nullable;
+
+/**
+ * Contains metadata associated with a Firebase user account, along with password hash and salt.
+ * Instances of this class are immutable and thread-safe.
+ */
+public class ExportedUserRecord extends UserRecord {
+
+  private final String passwordHash;
+  private final String passwordSalt;
+
+  ExportedUserRecord(User response) {
+    super(response);
+    this.passwordHash = response.getPasswordHash();
+    this.passwordSalt = response.getPasswordSalt();
+  }
+
+  /**
+   * Returns the user's password hash as a base64-encoded string.
+   *
+   * <p>If the Firebase Auth hashing algorithm (SCRYPT) was used to create the user account,
+   * returns the base64-encoded password hash of the user. If a different hashing algorithm was
+   * used to create this user, as is typical when migrating from another Auth system, returns
+   * an empty string. Returns null if no password is set.
+   *
+   * @return A base64-encoded password hash, possibly empty or null.
+   */
+  @Nullable
+  public String getPasswordHash() {
+    return passwordHash;
+  }
+
+  /**
+   * Returns the user's password salt as a base64-encoded string.
+   *
+   * <p>If the Firebase Auth hashing algorithm (SCRYPT) was used to create the user account,
+   * returns the base64-encoded password salt of the user. If a different hashing algorithm was
+   * used to create this user, as is typical when migrating from another Auth system, returns
+   * an empty string. Returns null if no password is set.
+   *
+   * @return A base64-encoded password salt, possibly empty or null.
+   */
+  @Nullable
+  public String getPasswordSalt() {
+    return passwordSalt;
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuth.java b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
index d7ab206..02ea5ad 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseAuth.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
@@ -30,11 +30,14 @@ import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.ImplFirebaseTrampolines;
+import com.google.firebase.auth.ListUsersPage.DefaultUserSource;
+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.internal.FirebaseService;
+import com.google.firebase.internal.Nullable;
 import com.google.firebase.internal.TaskToApiFuture;
 import com.google.firebase.tasks.Task;
 
@@ -346,6 +349,46 @@ public class FirebaseAuth {
     return new TaskToApiFuture<>(getUserByPhoneNumber(phoneNumber));
   }
 
+  private Task<ListUsersPage> listUsers(@Nullable String pageToken, int maxResults) {
+    checkNotDestroyed();
+    final PageFactory factory = new PageFactory(
+        new DefaultUserSource(userManager), maxResults, pageToken);
+    return call(new Callable<ListUsersPage>() {
+      @Override
+      public ListUsersPage call() throws Exception {
+        return factory.create();
+      }
+    });
+  }
+
+  /**
+   * Gets a page of users starting from the specified {@code pageToken}. Page size will be
+   * limited to 1000 users.
+   *
+   * @param pageToken A non-empty page token string, or null to retrieve the first page of users.
+   * @return An {@code ApiFuture} which will complete successfully with a {@link ListUsersPage}
+   *     instance. If an error occurs while retrieving user data, the future throws an exception.
+   * @throws IllegalArgumentException If the specified page token is empty.
+   */
+  public ApiFuture<ListUsersPage> listUsersAsync(@Nullable String pageToken) {
+    return listUsersAsync(pageToken, FirebaseUserManager.MAX_LIST_USERS_RESULTS);
+  }
+
+  /**
+   * Gets a page of users starting from the specified {@code pageToken}.
+   *
+   * @param pageToken A non-empty page token string, or null to retrieve the first page of users.
+   * @param maxResults Maximum number of users to include in the returned page. This may not
+   *     exceed 1000.
+   * @return An {@code ApiFuture} which will complete successfully with a {@link ListUsersPage}
+   *     instance. If an error occurs while retrieving user data, the future throws an exception.
+   * @throws IllegalArgumentException If the specified page token is empty, or max results value
+   *     is invalid.
+   */
+  public ApiFuture<ListUsersPage> listUsersAsync(@Nullable String pageToken, int maxResults) {
+    return new TaskToApiFuture<>(listUsers(pageToken, maxResults));
+  }
+
   /**
    * Similar to {@link #createUserAsync(CreateRequest)}, but returns a {@link Task}.
    *
@@ -398,7 +441,7 @@ public class FirebaseAuth {
     return call(new Callable<UserRecord>() {
       @Override
       public UserRecord call() throws Exception {
-        userManager.updateUser(request);
+        userManager.updateUser(request, jsonFactory);
         return userManager.getUserById(request.getUid());
       }
     });
@@ -418,6 +461,35 @@ public class FirebaseAuth {
     return new TaskToApiFuture<>(updateUser(request));
   }
 
+  private Task<Void> setCustomClaims(String uid, Map<String, Object> claims) {
+    checkNotDestroyed();
+    final UpdateRequest request = new UpdateRequest(uid).setCustomClaims(claims);
+    return call(new Callable<Void>() {
+      @Override
+      public Void call() throws Exception {
+        userManager.updateUser(request, jsonFactory);
+        return null;
+      }
+    });
+  }
+
+  /**
+   * Sets the specified custom claims on an existing user account. A null claims value removes
+   * any claims currently set on the user account. The claims should serialize into a valid JSON
+   * string. The serialized claims must not be larger than 1000 characters.
+   *
+   * @param uid A user ID string.
+   * @param claims A map of custom claims or null.
+   * @return An {@code ApiFuture} which will complete successfully when the user account has been
+   *     updated. If an error occurs while deleting the user account, the future throws a
+   *     {@link FirebaseAuthException}.
+   * @throws IllegalArgumentException If the user ID string is null or empty, or the claims
+   *     payload is invalid or too large.
+   */
+  public ApiFuture<Void> setCustomClaimsAsync(String uid, Map<String, Object> claims) {
+    return new TaskToApiFuture<>(setCustomClaims(uid, claims));
+  }
+
   /**
    * Similar to {@link #deleteUserAsync(String)}, but returns a {@link Task}.
    *
@@ -440,19 +512,6 @@ public class FirebaseAuth {
     });
   }
 
-  private void checkNotDestroyed() {
-    synchronized (lock) {
-      checkState(!destroyed.get(), "FirebaseAuth instance is no longer alive. This happens when "
-          + "the parent FirebaseApp instance has been deleted.");
-    }
-  }
-
-  private void destroy() {
-    synchronized (lock) {
-      destroyed.set(true);
-    }
-  }
-
   /**
    * Deletes the user identified by the specified user ID.
    *
@@ -470,6 +529,19 @@ public class FirebaseAuth {
     return ImplFirebaseTrampolines.submitCallable(firebaseApp, command);
   }
 
+  private void checkNotDestroyed() {
+    synchronized (lock) {
+      checkState(!destroyed.get(), "FirebaseAuth instance is no longer alive. This happens when "
+          + "the parent FirebaseApp instance has been deleted.");
+    }
+  }
+
+  private void destroy() {
+    synchronized (lock) {
+      destroyed.set(true);
+    }
+  }
+
   private static final String SERVICE_ID = FirebaseAuth.class.getName();
 
   private static class FirebaseAuthService extends FirebaseService<FirebaseAuth> {
diff --git a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
index 346b35d..a55ca5f 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
@@ -37,10 +37,12 @@ import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.firebase.auth.UserRecord.CreateRequest;
 import com.google.firebase.auth.UserRecord.UpdateRequest;
+import com.google.firebase.auth.internal.DownloadAccountResponse;
 import com.google.firebase.auth.internal.GetAccountInfoResponse;
 
 import com.google.firebase.internal.SdkUtils;
 import java.io.IOException;
+import java.util.List;
 import java.util.Map;
 
 /**
@@ -56,8 +58,15 @@ class FirebaseUserManager {
   static final String USER_CREATE_ERROR = "USER_CREATE_ERROR";
   static final String USER_UPDATE_ERROR = "USER_UPDATE_ERROR";
   static final String USER_DELETE_ERROR = "USER_DELETE_ERROR";
+  static final String LIST_USERS_ERROR = "LIST_USERS_ERROR";
   static final String INTERNAL_ERROR = "INTERNAL_ERROR";
 
+  static final int MAX_LIST_USERS_RESULTS = 1000;
+
+  static final List<String> RESERVED_CLAIMS = ImmutableList.of(
+      "amr", "at_hash", "aud", "auth_time", "azp", "cnf", "c_hash", "exp", "iat",
+      "iss", "jti", "nbf", "nonce", "sub", "firebase");
+
   private static final String ID_TOOLKIT_URL =
       "https://www.googleapis.com/identitytoolkit/v3/relyingparty/";
   private static final String CLIENT_VERSION_HEADER = "X-Client-Version";
@@ -157,10 +166,10 @@ class FirebaseUserManager {
     throw new FirebaseAuthException(USER_CREATE_ERROR, "Failed to create new user");
   }
 
-  void updateUser(UpdateRequest request) throws FirebaseAuthException {
+  void updateUser(UpdateRequest request, JsonFactory jsonFactory) throws FirebaseAuthException {
     GenericJson response;
     try {
-      response = post("setAccountInfo", request.getProperties(), GenericJson.class);
+      response = post("setAccountInfo", request.getProperties(jsonFactory), GenericJson.class);
     } catch (IOException e) {
       throw new FirebaseAuthException(USER_UPDATE_ERROR,
           "IO error while updating user: " + request.getUid(), e);
@@ -187,6 +196,28 @@ class FirebaseUserManager {
     }
   }
 
+  DownloadAccountResponse listUsers(int maxResults, String pageToken) throws FirebaseAuthException {
+    ImmutableMap.Builder<String, Object> builder = ImmutableMap.<String, Object>builder()
+        .put("maxResults", maxResults);
+    if (pageToken != null) {
+      checkArgument(!pageToken.equals(ListUsersPage.END_OF_LIST), "invalid end of list page token");
+      builder.put("nextPageToken", pageToken);
+    }
+
+    DownloadAccountResponse response;
+    try {
+      response = post("downloadAccount", builder.build(), DownloadAccountResponse.class);
+      if (response == null) {
+        throw new FirebaseAuthException(LIST_USERS_ERROR,
+            "Unexpected response from download user account API.");
+      }
+      return response;
+    } catch (IOException e) {
+      throw new FirebaseAuthException(LIST_USERS_ERROR,
+          "IO error while downloading user accounts.", e);
+    }
+  }
+
   private <T> T post(String path, Object content, Class<T> clazz) throws IOException {
     checkArgument(!Strings.isNullOrEmpty(path), "path must not be null or empty");
     checkNotNull(content, "content must not be null");
diff --git a/src/main/java/com/google/firebase/auth/ListUsersPage.java b/src/main/java/com/google/firebase/auth/ListUsersPage.java
new file mode 100644
index 0000000..866d9bd
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/ListUsersPage.java
@@ -0,0 +1,262 @@
+/*
+ * 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 static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.gax.paging.Page;
+import com.google.common.collect.ImmutableList;
+import com.google.firebase.auth.internal.DownloadAccountResponse;
+import com.google.firebase.internal.NonNull;
+import com.google.firebase.internal.Nullable;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+/**
+ * Represents a page of {@link ExportedUserRecord} instances. Provides methods for iterating
+ * over the users in the current page, and calling up subsequent pages of users. Instances of
+ * this class are thread-safe and immutable.
+ */
+public class ListUsersPage implements Page<ExportedUserRecord> {
+
+  static final String END_OF_LIST = "";
+
+  private final ListUsersResult currentBatch;
+  private final UserSource source;
+  private final int maxResults;
+
+  private ListUsersPage(
+      @NonNull ListUsersResult currentBatch, @NonNull UserSource source, int maxResults) {
+    this.currentBatch = checkNotNull(currentBatch);
+    this.source = checkNotNull(source);
+    this.maxResults = maxResults;
+  }
+
+  /**
+   * Checks if there is another page of users available to retrieve.
+   *
+   * @return true if another page is available, or false otherwise.
+   */
+  @Override
+  public boolean hasNextPage() {
+    return !END_OF_LIST.equals(currentBatch.getNextPageToken());
+  }
+
+  /**
+   * Returns the string token that identifies the next page. Never returns null. Returns empty
+   * string if there are no more pages available to be retrieved.
+   *
+   * @return A non-null string token (possibly empty, representing no more pages)
+   */
+  @NonNull
+  @Override
+  public String getNextPageToken() {
+    return currentBatch.getNextPageToken();
+  }
+
+  /**
+   * Returns the next page of users.
+   *
+   * @return A new {@link ListUsersPage} instance, or null if there are no more pages.
+   */
+  @Nullable
+  @Override
+  public ListUsersPage getNextPage() {
+    if (hasNextPage()) {
+      PageFactory factory = new PageFactory(source, maxResults, currentBatch.getNextPageToken());
+      return factory.create();
+    }
+    return null;
+  }
+
+  /**
+   * Returns an {@code Iterable} that facilitates transparently iterating over all the users in the
+   * current Firebase project, starting from this page. The {@code Iterator} instances produced
+   * by the returned {@code Iterable} never buffers more than one page of users at a time. It is
+   * safe to abandon the iterators (i.e. break the loops) at any time.
+   *
+   * @return a new {@code Iterable<ExportedUserRecord>} instance.
+   */
+  @NonNull
+  @Override
+  public Iterable<ExportedUserRecord> iterateAll() {
+    return new UserIterable(this);
+  }
+
+  /**
+   * Returns an {@code Iterable} over the users in this page.
+   *
+   * @return a {@code Iterable<ExportedUserRecord>} instance.
+   */
+  @NonNull
+  @Override
+  public Iterable<ExportedUserRecord> getValues() {
+    return currentBatch.getUsers();
+  }
+
+  private static class UserIterable implements Iterable<ExportedUserRecord> {
+
+    private final ListUsersPage startingPage;
+
+    UserIterable(@NonNull ListUsersPage startingPage) {
+      this.startingPage = checkNotNull(startingPage, "starting page must not be null");
+    }
+
+    @Override
+    @NonNull
+    public Iterator<ExportedUserRecord> iterator() {
+      return new UserIterator(startingPage);
+    }
+
+    /**
+     * An {@code Iterator} that cycles through user accounts, one at a time. It buffers the
+     * last retrieved batch of user accounts in memory. The {@code maxResults} parameter is an
+     * upper bound on the batch size.
+     */
+    private static class UserIterator implements Iterator<ExportedUserRecord> {
+
+      private ListUsersPage currentPage;
+      private List<ExportedUserRecord> batch;
+      private int index = 0;
+
+      private UserIterator(ListUsersPage startingPage) {
+        setCurrentPage(startingPage);
+      }
+
+      @Override
+      public boolean hasNext() {
+        if (index == batch.size()) {
+          if (currentPage.hasNextPage()) {
+            setCurrentPage(currentPage.getNextPage());
+          } else {
+            return false;
+          }
+        }
+
+        return index < batch.size();
+      }
+
+      @Override
+      public ExportedUserRecord next() {
+        if (!hasNext()) {
+          throw new NoSuchElementException();
+        }
+        return batch.get(index++);
+      }
+
+      @Override
+      public void remove() {
+        throw new UnsupportedOperationException("remove operation not supported");
+      }
+
+      private void setCurrentPage(ListUsersPage page) {
+        this.currentPage = checkNotNull(page);
+        this.batch = ImmutableList.copyOf(page.getValues());
+        this.index = 0;
+      }
+    }
+  }
+
+  /**
+   * Represents a source of user data that can be queried to load a batch of users.
+   */
+  interface UserSource {
+    @NonNull
+    ListUsersResult fetch(int maxResults, String pageToken);
+  }
+
+  static class DefaultUserSource implements UserSource {
+
+    private final FirebaseUserManager userManager;
+
+    DefaultUserSource(FirebaseUserManager userManager) {
+      this.userManager = checkNotNull(userManager, "user manager must not be null");
+    }
+
+    @Override
+    public ListUsersResult fetch(int maxResults, String pageToken) {
+      try {
+        DownloadAccountResponse response = userManager.listUsers(maxResults, pageToken);
+        ImmutableList.Builder<ExportedUserRecord> builder = ImmutableList.builder();
+        if (response.hasUsers()) {
+          for (DownloadAccountResponse.User user : response.getUsers()) {
+            builder.add(new ExportedUserRecord(user));
+          }
+        }
+        String nextPageToken = response.getPageToken() != null
+            ? response.getPageToken() : END_OF_LIST;
+        return new ListUsersResult(builder.build(), nextPageToken);
+      } catch (Exception e) {
+        throw new RuntimeException("Error while downloading user accounts", e);
+      }
+    }
+  }
+
+  static final class ListUsersResult {
+
+    private final List<ExportedUserRecord> users;
+    private final String nextPageToken;
+
+    ListUsersResult(@NonNull List<ExportedUserRecord> users, @NonNull String nextPageToken) {
+      this.users = checkNotNull(users);
+      this.nextPageToken = checkNotNull(nextPageToken); // Can be empty
+    }
+
+    @NonNull
+    List<ExportedUserRecord> getUsers() {
+      return users;
+    }
+
+    @NonNull
+    String getNextPageToken() {
+      return nextPageToken;
+    }
+  }
+
+  /**
+   * A simple factory class for {@link ListUsersPage} instances. Performs argument validation
+   * before attempting to load any user data (which is expensive, and hence may be performed
+   * asynchronously on a separate thread).
+   */
+  static class PageFactory {
+
+    private final UserSource source;
+    private final int maxResults;
+    private final String pageToken;
+
+    PageFactory(@NonNull UserSource source) {
+      this(source, FirebaseUserManager.MAX_LIST_USERS_RESULTS, null);
+    }
+
+    PageFactory(@NonNull UserSource source, int maxResults, @Nullable String pageToken) {
+      checkArgument(maxResults > 0 && maxResults <= FirebaseUserManager.MAX_LIST_USERS_RESULTS,
+          "maxResults must be a positive integer that does not exceed %s",
+          FirebaseUserManager.MAX_LIST_USERS_RESULTS);
+      checkArgument(!END_OF_LIST.equals(pageToken), "invalid end of list page token");
+      this.source = checkNotNull(source, "source must not be null");
+      this.maxResults = maxResults;
+      this.pageToken = pageToken;
+    }
+
+    ListUsersPage create() {
+      ListUsersResult batch = source.fetch(maxResults, pageToken);
+      return new ListUsersPage(batch, source, maxResults);
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/UserRecord.java b/src/main/java/com/google/firebase/auth/UserRecord.java
index 6d650e1..7d634b3 100644
--- a/src/main/java/com/google/firebase/auth/UserRecord.java
+++ b/src/main/java/com/google/firebase/auth/UserRecord.java
@@ -20,18 +20,22 @@ import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.api.client.json.JsonFactory;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.firebase.auth.internal.GetAccountInfoResponse;
+import com.google.firebase.auth.internal.GetAccountInfoResponse.User;
+import com.google.firebase.internal.NonNull;
 import com.google.firebase.internal.Nullable;
 
+import java.io.IOException;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.json.JSONObject;
 
 /**
  * Contains metadata associated with a Firebase user account. Instances of this class are immutable
@@ -43,6 +47,8 @@ public class UserRecord implements UserInfo {
   private static final Map<String, String> REMOVABLE_FIELDS = ImmutableMap.of(
       "displayName", "DISPLAY_NAME",
       "photoUrl", "PHOTO_URL");
+  private static final String CUSTOM_ATTRIBUTES = "customAttributes";
+  private static final int MAX_CLAIMS_PAYLOAD_SIZE = 1000;
 
   private final String uid;
   private final String email;
@@ -53,8 +59,9 @@ public class UserRecord implements UserInfo {
   private final boolean disabled;
   private final ProviderUserInfo[] providers;
   private final UserMetadata userMetadata;
+  private final Map<String, Object> customClaims;
 
-  UserRecord(GetAccountInfoResponse.User response) {
+  UserRecord(User response) {
     checkNotNull(response, "Response must not be null");
     checkArgument(!Strings.isNullOrEmpty(response.getUid()), "uid must not be null or empty");
     this.uid = response.getUid();
@@ -73,6 +80,13 @@ public class UserRecord implements UserInfo {
       }
     }
     this.userMetadata = new UserMetadata(response.getCreatedAt(), response.getLastLoginAt());
+    String customClaims = response.getCustomClaims();
+    if (!Strings.isNullOrEmpty(customClaims)) {
+      Map<String, Object> parsed = new JSONObject(customClaims).toMap();
+      this.customClaims = ImmutableMap.copyOf(parsed);
+    } else {
+      this.customClaims = ImmutableMap.of();
+    }
   }
 
   /**
@@ -158,8 +172,8 @@ public class UserRecord implements UserInfo {
   }
 
   /**
-   * Returns an array of UserInfo objects that represents the identities from different identity
-   * providers that are linked to this user.
+   * Returns an array of {@code UserInfo} objects that represents the identities from different
+   * identity providers that are linked to this user.
    *
    * @return an array of {@link UserInfo} instances, which may be empty.
    */
@@ -176,6 +190,16 @@ public class UserRecord implements UserInfo {
     return this.userMetadata;
   }
 
+  /**
+   * Returns custom claims set on this user.
+   *
+   * @return a non-null, immutable Map of custom claims, possibly empty.
+   */
+  @NonNull
+  public Map<String,Object> getCustomClaims() {
+    return customClaims;
+  }
+
   /**
    * Returns a new {@link UpdateRequest}, which can be used to update the attributes
    * of this user.
@@ -204,6 +228,32 @@ public class UserRecord implements UserInfo {
     checkArgument(password.length() >= 6, "password must be at least 6 characters long");
   }
 
+  private static void checkCustomClaims(Map<String,Object> customClaims) {
+    if (customClaims == null) {
+      return;
+    }
+    for (String key : customClaims.keySet()) {
+      checkArgument(!FirebaseUserManager.RESERVED_CLAIMS.contains(key),
+          "Claim '" + key + "' is reserved and cannot be set");
+    }
+  }
+
+  private static String serializeCustomClaims(Map customClaims, JsonFactory jsonFactory) {
+    checkNotNull(jsonFactory, "JsonFactory must not be null");
+    if (customClaims == null || customClaims.isEmpty()) {
+      return "{}";
+    }
+
+    try {
+      String claimsPayload = jsonFactory.toString(customClaims);
+      checkArgument(claimsPayload.length() <= MAX_CLAIMS_PAYLOAD_SIZE,
+          "customClaims payload cannot be larger than " + MAX_CLAIMS_PAYLOAD_SIZE + " characters");
+      return claimsPayload;
+    } catch (IOException e) {
+      throw new IllegalArgumentException("Failed to serialize custom claims into JSON", e);
+    }
+  }
+
   /**
    * A specification class for creating new user accounts. Set the initial attributes of the new
    * user account by calling various setter methods available in this class. None of the attributes
@@ -430,7 +480,19 @@ public class UserRecord implements UserInfo {
       return this;
     }
 
-    Map<String, Object> getProperties() {
+    /**
+     * Updates the custom claims associated with this user. Calling this method with a null
+     * argument removes any custom claims from the user account.
+     *
+     * @param customClaims a Map of custom claims or null
+     */
+    public UpdateRequest setCustomClaims(Map<String,Object> customClaims) {
+      checkCustomClaims(customClaims);
+      properties.put(CUSTOM_ATTRIBUTES, customClaims);
+      return this;
+    }
+
+    Map<String, Object> getProperties(JsonFactory jsonFactory) {
       Map<String, Object> copy = new HashMap<>(properties);
       List<String> remove = new ArrayList<>();
       for (Map.Entry<String, String> entry : REMOVABLE_FIELDS.entrySet()) {
@@ -448,6 +510,11 @@ public class UserRecord implements UserInfo {
         copy.put("deleteProvider", ImmutableList.of("phone"));
         copy.remove("phoneNumber");
       }
+
+      if (copy.containsKey(CUSTOM_ATTRIBUTES)) {
+        Map customClaims = (Map) copy.remove(CUSTOM_ATTRIBUTES);
+        copy.put(CUSTOM_ATTRIBUTES, serializeCustomClaims(customClaims, jsonFactory));
+      }
       return ImmutableMap.copyOf(copy);
     }
   }
diff --git a/src/main/java/com/google/firebase/auth/internal/DownloadAccountResponse.java b/src/main/java/com/google/firebase/auth/internal/DownloadAccountResponse.java
new file mode 100644
index 0000000..c469a4a
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/internal/DownloadAccountResponse.java
@@ -0,0 +1,65 @@
+/*
+ * 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 com.google.api.client.util.Key;
+import com.google.firebase.auth.internal.GetAccountInfoResponse.User;
+import java.util.List;
+
+/**
+ * JSON data binding for downloadAccountResponse messages sent by Google identity toolkit service.
+ */
+public class DownloadAccountResponse {
+
+  @Key("users")
+  private List<User> users;
+
+  @Key("nextPageToken")
+  private String pageToken;
+
+  public List<User> getUsers() {
+    return users;
+  }
+
+  public boolean hasUsers() {
+    return users != null && !users.isEmpty();
+  }
+
+  public String getPageToken() {
+    return pageToken;
+  }
+
+  /**
+   * JSON data binding for exported user records.
+   */
+  public static final class User extends GetAccountInfoResponse.User {
+
+    @Key("passwordHash")
+    private String passwordHash;
+
+    @Key("salt")
+    private String passwordSalt;
+
+    public String getPasswordHash() {
+      return passwordHash;
+    }
+
+    public String getPasswordSalt() {
+      return passwordSalt;
+    }
+  }
+}
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 f648667..9c53303 100644
--- a/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java
+++ b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java
@@ -41,7 +41,7 @@ public final class GetAccountInfoResponse {
   /**
    * JSON data binding for user records.
    */
-  public static final class User {
+  public static class User {
 
     @Key("localId")
     private String uid;
@@ -73,6 +73,9 @@ public final class GetAccountInfoResponse {
     @Key("lastLoginAt")
     private long lastLoginAt;
 
+    @Key("customAttributes")
+    private String customClaims;
+
     public String getUid() {
       return uid;
     }
@@ -112,6 +115,10 @@ public final class GetAccountInfoResponse {
     public long getLastLoginAt() {
       return lastLoginAt;
     }
+
+    public String getCustomClaims() {
+      return customClaims;
+    }
   }
 
   /**


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 b112bbe..d1433dc 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
@@ -18,6 +18,7 @@ package com.google.firebase.auth;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
@@ -31,6 +32,9 @@ import com.google.api.client.http.json.JsonHttpContent;
 import com.google.api.client.json.GenericJson;
 import com.google.api.client.json.JsonFactory;
 import com.google.api.client.json.JsonObjectParser;
+import com.google.api.core.ApiFuture;
+import com.google.api.core.ApiFutureCallback;
+import com.google.api.core.ApiFutures;
 import com.google.common.collect.ImmutableMap;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.auth.UserRecord.CreateRequest;
@@ -43,6 +47,10 @@ import java.util.Map;
 import java.util.Random;
 import java.util.UUID;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
 import org.junit.BeforeClass;
 import org.junit.Test;
 
@@ -157,17 +165,6 @@ public class FirebaseAuthIT {
     }
   }
 
-  private void checkRecreate(String uid) throws Exception {
-    try {
-      auth.createUserAsync(new CreateRequest().setUid(uid)).get();
-      fail("No error thrown for creating user with existing ID");
-    } catch (ExecutionException e) {
-      assertTrue(e.getCause() instanceof FirebaseAuthException);
-      assertEquals(FirebaseUserManager.USER_CREATE_ERROR,
-          ((FirebaseAuthException) e.getCause()).getErrorCode());
-    }
-  }
-
   @Test
   public void testUserLifecycle() throws Exception {
     // Create user
@@ -186,6 +183,7 @@ public class FirebaseAuthIT {
     assertTrue(userRecord.getUserMetadata().getCreationTimestamp() > 0);
     assertEquals(0, userRecord.getUserMetadata().getLastSignInTimestamp());
     assertEquals(0, userRecord.getProviderData().length);
+    assertTrue(userRecord.getCustomClaims().isEmpty());
 
     // Update user
     String randomId = UUID.randomUUID().toString().replaceAll("-", "");
@@ -208,6 +206,7 @@ public class FirebaseAuthIT {
     assertTrue(userRecord.isEmailVerified());
     assertFalse(userRecord.isDisabled());
     assertEquals(2, userRecord.getProviderData().length);
+    assertTrue(userRecord.getCustomClaims().isEmpty());
 
     // Get user by email
     userRecord = auth.getUserByEmailAsync(userRecord.getEmail()).get();
@@ -228,6 +227,7 @@ public class FirebaseAuthIT {
     assertTrue(userRecord.isEmailVerified());
     assertTrue(userRecord.isDisabled());
     assertEquals(1, userRecord.getProviderData().length);
+    assertTrue(userRecord.getCustomClaims().isEmpty());
 
     // Delete user
     auth.deleteUserAsync(userRecord.getUid()).get();
@@ -241,6 +241,114 @@ public class FirebaseAuthIT {
     }
   }
 
+  @Test
+  public void testListUsers() throws Exception {
+    final List<String> uids = new ArrayList<>();
+
+    try {
+      uids.add(auth.createUserAsync(new CreateRequest().setPassword("password")).get().getUid());
+      uids.add(auth.createUserAsync(new CreateRequest().setPassword("password")).get().getUid());
+      uids.add(auth.createUserAsync(new CreateRequest().setPassword("password")).get().getUid());
+
+      // Test list by batches
+      final AtomicInteger collected = new AtomicInteger(0);
+      ListUsersPage page = auth.listUsersAsync(null).get();
+      while (page != null) {
+        for (ExportedUserRecord user : page.getValues()) {
+          if (uids.contains(user.getUid())) {
+            collected.incrementAndGet();
+            assertNotNull(user.getPasswordHash());
+            assertNotNull(user.getPasswordSalt());
+          }
+        }
+        page = page.getNextPage();
+      }
+      assertEquals(uids.size(), collected.get());
+
+      // Test iterate all
+      collected.set(0);
+      page = auth.listUsersAsync(null).get();
+      for (ExportedUserRecord user : page.iterateAll()) {
+        if (uids.contains(user.getUid())) {
+          collected.incrementAndGet();
+          assertNotNull(user.getPasswordHash());
+          assertNotNull(user.getPasswordSalt());
+        }
+      }
+      assertEquals(uids.size(), collected.get());
+
+      // Test iterate async
+      collected.set(0);
+      final Semaphore semaphore = new Semaphore(0);
+      final AtomicReference<Throwable> error = new AtomicReference<>();
+      ApiFuture<ListUsersPage> pageFuture = auth.listUsersAsync(null);
+      ApiFutures.addCallback(pageFuture, new ApiFutureCallback<ListUsersPage>() {
+        @Override
+        public void onFailure(Throwable t) {
+          error.set(t);
+          semaphore.release();
+        }
+
+        @Override
+        public void onSuccess(ListUsersPage result) {
+          for (ExportedUserRecord user : result.iterateAll()) {
+            if (uids.contains(user.getUid())) {
+              collected.incrementAndGet();
+              assertNotNull(user.getPasswordHash());
+              assertNotNull(user.getPasswordSalt());
+            }
+          }
+          semaphore.release();
+        }
+      });
+      semaphore.acquire();
+      assertEquals(uids.size(), collected.get());
+      assertNull(error.get());
+    } finally {
+      for (String uid : uids) {
+        auth.deleteUserAsync(uid).get();
+      }
+    }
+  }
+
+  @Test
+  public void testCustomClaims() throws Exception {
+    UserRecord userRecord = auth.createUserAsync(new CreateRequest()).get();
+    String uid = userRecord.getUid();
+
+    try {
+      // New user should not have any claims
+      assertTrue(userRecord.getCustomClaims().isEmpty());
+
+      Map<String, Object> expected = ImmutableMap.<String, Object>of(
+          "admin", true, "package", "gold");
+      auth.setCustomClaimsAsync(uid, expected).get();
+
+      // Should have 2 claims
+      UserRecord updatedUser = auth.getUserAsync(uid).get();
+      assertEquals(2, updatedUser.getCustomClaims().size());
+      for (Map.Entry<String, Object> entry : expected.entrySet()) {
+        assertEquals(entry.getValue(), updatedUser.getCustomClaims().get(entry.getKey()));
+      }
+
+      // User's ID token should have the custom claims
+      String customToken = auth.createCustomTokenAsync(uid).get();
+      String idToken = signInWithCustomToken(customToken);
+      FirebaseToken decoded = auth.verifyIdTokenAsync(idToken).get();
+      Map<String, Object> result = decoded.getClaims();
+      for (Map.Entry<String, Object> entry : expected.entrySet()) {
+        assertEquals(entry.getValue(), result.get(entry.getKey()));
+      }
+
+      // Should be able to remove custom claims
+      auth.setCustomClaimsAsync(uid, null).get();
+      updatedUser = auth.getUserAsync(uid).get();
+      assertTrue(updatedUser.getCustomClaims().isEmpty());
+    } finally {
+      auth.deleteUserAsync(uid).get();
+    }
+  }
+
   @Test
   public void testCustomToken() throws Exception {
     String customToken = auth.createCustomTokenAsync("user1").get();
@@ -278,4 +386,15 @@ public class FirebaseAuthIT {
     }
   }
 
+  private void checkRecreate(String uid) throws Exception {
+    try {
+      auth.createUserAsync(new CreateRequest().setUid(uid)).get();
+      fail("No error thrown for creating user with existing ID");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseAuthException);
+      assertEquals(FirebaseUserManager.USER_CREATE_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 ba9e3b9..1e82ea9 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
@@ -272,7 +272,7 @@ public class FirebaseAuthTest {
       }
       try {
         method.invoke(auth, parameters.toArray());
-        fail("No error thrown when invoking auth after deleting app");
+        fail("No error thrown when invoking auth after deleting app; method: " + method.getName());
       } catch (InvocationTargetException expected) {
         String message = "FirebaseAuth instance is no longer alive. This happens when "
             + "the parent FirebaseApp instance has been deleted.";
diff --git a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
index 42ee62c..325bf9d 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
@@ -22,21 +22,30 @@ import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
+import com.google.api.client.googleapis.util.Utils;
 import com.google.api.client.http.HttpHeaders;
 import com.google.api.client.http.HttpResponse;
 import com.google.api.client.http.HttpResponseInterceptor;
+import com.google.api.client.json.GenericJson;
+import com.google.api.client.json.JsonFactory;
 import com.google.api.client.json.gson.GsonFactory;
 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.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.firebase.auth.UserRecord.CreateRequest;
 import com.google.firebase.auth.UserRecord.UpdateRequest;
+import com.google.firebase.auth.internal.DownloadAccountResponse;
+import com.google.firebase.auth.internal.DownloadAccountResponse.User;
 import com.google.firebase.internal.SdkUtils;
 import com.google.firebase.testing.TestUtils;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.math.BigDecimal;
 import java.util.Map;
+
 import org.junit.Test;
 
 public class FirebaseUserManagerTest {
@@ -106,6 +115,82 @@ public class FirebaseUserManagerTest {
     checkRequestHeaders(interceptor);
   }
 
+  @Test
+  public void testListUsers() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    response.setContent(TestUtils.loadResource("listUsers.json"));
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(response)
+        .build();
+    FirebaseUserManager userManager = new FirebaseUserManager(gson, transport, credentials);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    userManager.setInterceptor(interceptor);
+
+    DownloadAccountResponse download = userManager.listUsers(999, null);
+    assertEquals(2, download.getUsers().size());
+    for (User user : download.getUsers()) {
+      ExportedUserRecord userRecord = new ExportedUserRecord(user);
+      checkUserRecord(userRecord);
+      assertEquals("passwordHash", userRecord.getPasswordHash());
+      assertEquals("passwordSalt", userRecord.getPasswordSalt());
+    }
+    assertNull(download.getPageToken());
+    checkRequestHeaders(interceptor);
+
+    JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    interceptor.response.getRequest().getContent().writeTo(out);
+    GenericJson parsed = jsonFactory.fromString(new String(out.toByteArray()), GenericJson.class);
+    assertEquals(new BigDecimal(999), parsed.get("maxResults"));
+    assertNull(parsed.get("nextPageToken"));
+  }
+
+  @Test
+  public void testListUsersWithPageToken() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    response.setContent(TestUtils.loadResource("listUsers.json"));
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(response)
+        .build();
+    FirebaseUserManager userManager = new FirebaseUserManager(gson, transport, credentials);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    userManager.setInterceptor(interceptor);
+
+    DownloadAccountResponse download = userManager.listUsers(999, "token");
+    assertEquals(2, download.getUsers().size());
+    for (User user : download.getUsers()) {
+      ExportedUserRecord userRecord = new ExportedUserRecord(user);
+      checkUserRecord(userRecord);
+      assertEquals("passwordHash", userRecord.getPasswordHash());
+      assertEquals("passwordSalt", userRecord.getPasswordSalt());
+    }
+    assertNull(download.getPageToken());
+    checkRequestHeaders(interceptor);
+
+    JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    interceptor.response.getRequest().getContent().writeTo(out);
+    GenericJson parsed = jsonFactory.fromString(new String(out.toByteArray()), GenericJson.class);
+    assertEquals(new BigDecimal(999), parsed.get("maxResults"));
+    assertEquals("token", parsed.get("nextPageToken"));
+  }
+
+  @Test
+  public void testListZeroUsers() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    response.setContent("{}");
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(response)
+        .build();
+    FirebaseUserManager userManager = new FirebaseUserManager(gson, transport, credentials);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    userManager.setInterceptor(interceptor);
+    DownloadAccountResponse download = userManager.listUsers(999, null);
+    assertNull(download.getUsers());
+    assertNull(download.getPageToken());
+    checkRequestHeaders(interceptor);
+  }
+
   @Test
   public void testCreateUser() throws Exception {
     MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
@@ -132,10 +217,35 @@ public class FirebaseUserManagerTest {
     TestResponseInterceptor interceptor = new TestResponseInterceptor();
     userManager.setInterceptor(interceptor);
     // should not throw
-    userManager.updateUser(new UpdateRequest("testuser"));
+    userManager.updateUser(new UpdateRequest("testuser"), Utils.getDefaultJsonFactory());
     checkRequestHeaders(interceptor);
   }
 
+  @Test
+  public void testSetCustomAttributes() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    response.setContent(TestUtils.loadResource("createUser.json"));
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(response)
+        .build();
+    FirebaseUserManager userManager = new FirebaseUserManager(gson, transport, credentials);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    userManager.setInterceptor(interceptor);
+    // should not throw
+    ImmutableMap<String, Object> claims = ImmutableMap.<String, Object>of(
+        "admin", true, "package", "gold");
+    JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
+    userManager.updateUser(new UpdateRequest("testuser")
+        .setCustomClaims(claims), jsonFactory);
+    checkRequestHeaders(interceptor);
+
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    interceptor.response.getRequest().getContent().writeTo(out);
+    GenericJson parsed = jsonFactory.fromString(new String(out.toByteArray()), GenericJson.class);
+    assertEquals("testuser", parsed.get("localId"));
+    assertEquals(jsonFactory.toString(claims), parsed.get("customAttributes"));
+  }
+
   @Test
   public void testDeleteUser() throws Exception {
     MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
@@ -344,8 +454,9 @@ public class FirebaseUserManagerTest {
   }
 
   @Test
-  public void testUserUpdater() {
+  public void testUserUpdater() throws IOException {
     UpdateRequest update = new UpdateRequest("test");
+    Map<String, Object> claims = ImmutableMap.<String, Object>of("admin", true, "package", "gold");
     Map<String, Object> map = update
         .setDisplayName("Display Name")
         .setPhotoUrl("http://test.com/example.png")
@@ -353,8 +464,9 @@ public class FirebaseUserManagerTest {
         .setPhoneNumber("+1234567890")
         .setEmailVerified(true)
         .setPassword("secret")
-        .getProperties();
-    assertEquals(7, map.size());
+        .setCustomClaims(claims)
+        .getProperties(Utils.getDefaultJsonFactory());
+    assertEquals(8, map.size());
     assertEquals(update.getUid(), map.get("localId"));
     assertEquals("Display Name", map.get("displayName"));
     assertEquals("http://test.com/example.png", map.get("photoUrl"));
@@ -362,13 +474,49 @@ public class FirebaseUserManagerTest {
     assertEquals("+1234567890", map.get("phoneNumber"));
     assertTrue((Boolean) map.get("emailVerified"));
     assertEquals("secret", map.get("password"));
+    assertEquals(Utils.getDefaultJsonFactory().toString(claims), map.get("customAttributes"));
+  }
+
+  @Test
+  public void testNullJsonFactory() {
+    UpdateRequest update = new UpdateRequest("test");
+    Map<String, Object> claims = ImmutableMap.<String, Object>of("admin", true, "package", "gold");
+    update.setCustomClaims(claims);
+    try {
+      update.getProperties(null);
+      fail("No error thrown for null JsonFactory");
+    } catch (NullPointerException ignore) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testNullCustomClaims() {
+    UpdateRequest update = new UpdateRequest("test");
+    Map<String, Object> map = update
+        .setCustomClaims(null)
+        .getProperties(Utils.getDefaultJsonFactory());
+    assertEquals(2, map.size());
+    assertEquals(update.getUid(), map.get("localId"));
+    assertEquals("{}", map.get("customAttributes"));
+  }
+
+  @Test
+  public void testEmptyCustomClaims() {
+    UpdateRequest update = new UpdateRequest("test");
+    Map<String, Object> map = update
+        .setCustomClaims(ImmutableMap.<String, Object>of())
+        .getProperties(Utils.getDefaultJsonFactory());
+    assertEquals(2, map.size());
+    assertEquals(update.getUid(), map.get("localId"));
+    assertEquals("{}", map.get("customAttributes"));
   }
 
   @Test
   public void testDeleteDisplayName() {
     Map<String, Object> map = new UpdateRequest("test")
         .setDisplayName(null)
-        .getProperties();
+        .getProperties(Utils.getDefaultJsonFactory());
     assertEquals(ImmutableList.of("DISPLAY_NAME"), map.get("deleteAttribute"));
   }
 
@@ -376,7 +524,7 @@ public class FirebaseUserManagerTest {
   public void testDeletePhotoUrl() {
     Map<String, Object> map = new UpdateRequest("test")
         .setPhotoUrl(null)
-        .getProperties();
+        .getProperties(Utils.getDefaultJsonFactory());
     assertEquals(ImmutableList.of("PHOTO_URL"), map.get("deleteAttribute"));
   }
 
@@ -384,7 +532,7 @@ public class FirebaseUserManagerTest {
   public void testDeletePhoneNumber() {
     Map<String, Object> map = new UpdateRequest("test")
         .setPhoneNumber(null)
-        .getProperties();
+        .getProperties(Utils.getDefaultJsonFactory());
     assertEquals(ImmutableList.of("phone"), map.get("deleteProvider"));
   }
 
@@ -468,6 +616,35 @@ public class FirebaseUserManagerTest {
     }
   }
 
+  @Test
+  public void testInvalidCustomClaims() {
+    UpdateRequest update = new UpdateRequest("test");
+    for (String claim : FirebaseUserManager.RESERVED_CLAIMS) {
+      try {
+        update.setCustomClaims(ImmutableMap.<String, Object>of(claim, "value"));
+        fail("No error thrown for reserved claim");
+      } catch (Exception ignore) {
+        // expected
+      }
+    }
+  }
+
+  @Test
+  public void testLargeCustomClaims() {
+    final StringBuilder builder = new StringBuilder();
+    for (int i = 0; i < 1001; i++) {
+      builder.append("a");
+    }
+    UpdateRequest update = new UpdateRequest("test");
+    update.setCustomClaims(ImmutableMap.<String, Object>of("key", builder.toString()));
+    try {
+      update.getProperties(Utils.getDefaultJsonFactory());
+      fail("No error thrown for large claims payload");
+    } catch (Exception ignore) {
+      // expected
+    }
+  }
+
   private void checkUserRecord(UserRecord userRecord) {
     assertEquals("testuser", userRecord.getUid());
     assertEquals("testuser@example.com", userRecord.getEmail());
@@ -493,6 +670,11 @@ public class FirebaseUserManagerTest {
     assertNull(provider.getEmail());
     assertEquals("+1234567890", provider.getPhoneNumber());
     assertEquals("phone", provider.getProviderId());
+
+    Map<String, Object> claims = userRecord.getCustomClaims();
+    assertEquals(2, claims.size());
+    assertTrue((boolean) claims.get("admin"));
+    assertEquals("gold", claims.get("package"));
   }
 
   private void checkRequestHeaders(TestResponseInterceptor interceptor) {
diff --git a/src/test/java/com/google/firebase/auth/ListUsersPageTest.java b/src/test/java/com/google/firebase/auth/ListUsersPageTest.java
new file mode 100644
index 0000000..e70c414
--- /dev/null
+++ b/src/test/java/com/google/firebase/auth/ListUsersPageTest.java
@@ -0,0 +1,352 @@
+/*
+ * 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 static junit.framework.TestCase.assertTrue;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.json.JsonFactory;
+import com.google.common.collect.ImmutableList;
+import com.google.firebase.auth.ListUsersPage.ListUsersResult;
+import com.google.firebase.auth.internal.DownloadAccountResponse;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import org.junit.Test;
+
+public class ListUsersPageTest {
+
+  @Test
+  public void testSinglePage() throws IOException {
+    TestUserSource source = new TestUserSource(3);
+    ListUsersPage page = new ListUsersPage.PageFactory(source).create();
+    assertFalse(page.hasNextPage());
+    assertEquals(ListUsersPage.END_OF_LIST, page.getNextPageToken());
+    assertNull(page.getNextPage());
+
+    ImmutableList<ExportedUserRecord> users = ImmutableList.copyOf(page.getValues());
+    assertEquals(3, users.size());
+    for (int i = 0; i < 3; i++) {
+      assertEquals("user" + i, users.get(i).getUid());
+    }
+    assertEquals(1, source.calls.size());
+    assertNull(source.calls.get(0));
+  }
+
+  @Test
+  public void testMultiplePages() throws IOException {
+    ListUsersResult result = new ListUsersResult(
+        ImmutableList.of(newUser("user0"), newUser("user1"), newUser("user2")),
+        "token");
+    TestUserSource source = new TestUserSource(result);
+    ListUsersPage page1 = new ListUsersPage.PageFactory(source).create();
+    assertTrue(page1.hasNextPage());
+    assertEquals("token", page1.getNextPageToken());
+    ImmutableList<ExportedUserRecord> users = ImmutableList.copyOf(page1.getValues());
+    assertEquals(3, users.size());
+    for (int i = 0; i < 3; i++) {
+      assertEquals("user" + i, users.get(i).getUid());
+    }
+
+    result = new ListUsersResult(
+        ImmutableList.of(newUser("user3"), newUser("user4"), newUser("user5")),
+        ListUsersPage.END_OF_LIST);
+    source.result = result;
+    ListUsersPage page2 = page1.getNextPage();
+    assertFalse(page2.hasNextPage());
+    assertEquals(ListUsersPage.END_OF_LIST, page2.getNextPageToken());
+    users = ImmutableList.copyOf(page2.getValues());
+    assertEquals(3, users.size());
+    for (int i = 3; i < 6; i++) {
+      assertEquals("user" + i, users.get(i - 3).getUid());
+    }
+
+    assertEquals(2, source.calls.size());
+    assertNull(source.calls.get(0));
+    assertEquals("token", source.calls.get(1));
+
+    // Should iterate all users from both pages
+    int iterations = 0;
+    for (ExportedUserRecord user : page1.iterateAll()) {
+      iterations++;
+    }
+    assertEquals(6, iterations);
+    assertEquals(3, source.calls.size());
+    assertEquals("token", source.calls.get(2));
+
+    // Should only iterate users in the last page
+    iterations = 0;
+    for (ExportedUserRecord user : page2.iterateAll()) {
+      iterations++;
+    }
+    assertEquals(3, iterations);
+    assertEquals(3, source.calls.size());
+  }
+
+  @Test
+  public void testListUsersIterable() throws IOException {
+    TestUserSource source = new TestUserSource(3);
+    ListUsersPage page = new ListUsersPage.PageFactory(source).create();
+    Iterable<ExportedUserRecord> users = page.iterateAll();
+
+    int iterations = 0;
+    for (ExportedUserRecord user : users) {
+      assertEquals("user" + iterations, user.getUid());
+      iterations++;
+    }
+    assertEquals(3, iterations);
+    assertEquals(1, source.calls.size());
+    assertNull(source.calls.get(0));
+
+    // Should result in a new iterator
+    iterations = 0;
+    for (ExportedUserRecord user : users) {
+      assertEquals("user" + iterations, user.getUid());
+      iterations++;
+    }
+    assertEquals(3, iterations);
+    assertEquals(1, source.calls.size());
+    assertNull(source.calls.get(0));
+  }
+
+  @Test
+  public void testListUsersIterator() throws IOException {
+    TestUserSource source = new TestUserSource(3);
+    ListUsersPage page = new ListUsersPage.PageFactory(source).create();
+    Iterable<ExportedUserRecord> users = page.iterateAll();
+    Iterator<ExportedUserRecord> iterator = users.iterator();
+    int iterations = 0;
+    while (iterator.hasNext()) {
+      assertEquals("user" + iterations, iterator.next().getUid());
+      iterations++;
+    }
+    assertEquals(3, iterations);
+    assertEquals(1, source.calls.size());
+    assertNull(source.calls.get(0));
+
+    while (iterator.hasNext()) {
+      fail("Should not be able to to iterate any more");
+    }
+    try {
+      iterator.next();
+      fail("Should not be able to iterate any more");
+    } catch (NoSuchElementException expected) {
+      // expected
+    }
+    assertEquals(1, source.calls.size());
+  }
+
+  @Test
+  public void testListUsersPagedIterable() throws IOException {
+    ListUsersResult result = new ListUsersResult(
+        ImmutableList.of(newUser("user0"), newUser("user1"), newUser("user2")),
+        "token");
+    TestUserSource source = new TestUserSource(result);
+    ListUsersPage page = new ListUsersPage.PageFactory(source).create();
+    int iterations = 0;
+    for (ExportedUserRecord user : page.iterateAll()) {
+      assertEquals("user" + iterations, user.getUid());
+      iterations++;
+      if (iterations == 3) {
+        assertEquals(1, source.calls.size());
+        assertNull(source.calls.get(0));
+        result = new ListUsersResult(
+            ImmutableList.of(newUser("user3"), newUser("user4"), newUser("user5")),
+            ListUsersPage.END_OF_LIST);
+        source.result = result;
+      }
+    }
+
+    assertEquals(6, iterations);
+    assertEquals(2, source.calls.size());
+    assertEquals("token", source.calls.get(1));
+  }
+
+  @Test
+  public void testListUsersPagedIterator() throws IOException {
+    ListUsersResult result = new ListUsersResult(
+        ImmutableList.of(newUser("user0"), newUser("user1"), newUser("user2")),
+        "token");
+    TestUserSource source = new TestUserSource(result);
+    ListUsersPage page = new ListUsersPage.PageFactory(source).create();
+    Iterator<ExportedUserRecord> users = page.iterateAll().iterator();
+    int iterations = 0;
+    while (users.hasNext()) {
+      assertEquals("user" + iterations, users.next().getUid());
+      iterations++;
+      if (iterations == 3) {
+        assertEquals(1, source.calls.size());
+        assertNull(source.calls.get(0));
+        result = new ListUsersResult(
+            ImmutableList.of(newUser("user3"), newUser("user4"), newUser("user5")),
+            ListUsersPage.END_OF_LIST);
+        source.result = result;
+      }
+    }
+
+    assertEquals(6, iterations);
+    assertEquals(2, source.calls.size());
+    assertEquals("token", source.calls.get(1));
+    assertFalse(users.hasNext());
+    try {
+      users.next();
+    } catch (NoSuchElementException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testPageWithNoUsers() {
+    ListUsersResult result = new ListUsersResult(
+        ImmutableList.<ExportedUserRecord>of(),
+        ListUsersPage.END_OF_LIST);
+    TestUserSource source = new TestUserSource(result);
+    ListUsersPage page = new ListUsersPage.PageFactory(source).create();
+    assertFalse(page.hasNextPage());
+    assertEquals(ListUsersPage.END_OF_LIST, page.getNextPageToken());
+    assertNull(page.getNextPage());
+    assertEquals(0, ImmutableList.copyOf(page.getValues()).size());
+    assertEquals(1, source.calls.size());
+  }
+
+  @Test
+  public void testIterableWithNoUsers() {
+    ListUsersResult result = new ListUsersResult(
+        ImmutableList.<ExportedUserRecord>of(),
+        ListUsersPage.END_OF_LIST);
+    TestUserSource source = new TestUserSource(result);
+    ListUsersPage page = new ListUsersPage.PageFactory(source).create();
+    for (ExportedUserRecord user : page.iterateAll()) {
+      fail("Should not be able to iterate, but got: " + user);
+    }
+    assertEquals(1, source.calls.size());
+  }
+
+  @Test
+  public void testIteratorWithNoUsers() {
+    ListUsersResult result = new ListUsersResult(
+        ImmutableList.<ExportedUserRecord>of(),
+        ListUsersPage.END_OF_LIST);
+    TestUserSource source = new TestUserSource(result);
+
+    ListUsersPage page = new ListUsersPage.PageFactory(source).create();
+    Iterator<ExportedUserRecord> iterator = page.iterateAll().iterator();
+    while (iterator.hasNext()) {
+      fail("Should not be able to iterate");
+    }
+    assertEquals(1, source.calls.size());
+  }
+
+  @Test
+  public void testRemove() throws IOException {
+    ListUsersResult result = new ListUsersResult(
+        ImmutableList.of(newUser("user1")),
+        ListUsersPage.END_OF_LIST);
+    TestUserSource source = new TestUserSource(result);
+
+    ListUsersPage page = new ListUsersPage.PageFactory(source).create();
+    Iterator<ExportedUserRecord> iterator = page.iterateAll().iterator();
+    while (iterator.hasNext()) {
+      assertNotNull(iterator.next());
+      try {
+        iterator.remove();
+      } catch (UnsupportedOperationException expected) {
+        // expected
+      }
+    }
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testNullSource() {
+    new ListUsersPage.PageFactory(null);
+  }
+
+  @Test
+  public void testInvalidPageToken() throws IOException {
+    TestUserSource source = new TestUserSource(1);
+    try {
+      new ListUsersPage.PageFactory(source, 1000, "");
+      fail("No error thrown for empty page token");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testInvalidMaxResults() throws IOException {
+    TestUserSource source = new TestUserSource(1);
+    try {
+      new ListUsersPage.PageFactory(source, 1001, "");
+      fail("No error thrown for maxResult > 1000");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+
+    try {
+      new ListUsersPage.PageFactory(source, 0, "next");
+      fail("No error thrown for maxResult = 0");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+
+    try {
+      new ListUsersPage.PageFactory(source, -1, "next");
+      fail("No error thrown for maxResult < 0");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  private static ExportedUserRecord newUser(String uid) throws IOException {
+    JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
+    DownloadAccountResponse.User parsed = jsonFactory.fromString(
+        String.format("{\"localId\":\"%s\"}", uid), DownloadAccountResponse.User.class);
+    return new ExportedUserRecord(parsed);
+  }
+
+  private static class TestUserSource implements ListUsersPage.UserSource {
+
+    private ListUsersResult result;
+    private List<String> calls = new ArrayList<>();
+
+    TestUserSource(int userCount) throws IOException {
+      ImmutableList.Builder<ExportedUserRecord> users = ImmutableList.builder();
+      for (int i = 0; i < userCount; i++) {
+        users.add(newUser("user" + i));
+      }
+      this.result = new ListUsersResult(users.build(), ListUsersPage.END_OF_LIST);
+    }
+
+    TestUserSource(ListUsersResult result) {
+      this.result = result;
+    }
+
+    @Override
+    public ListUsersResult fetch(int maxResults, String pageToken) {
+      calls.add(pageToken);
+      return result;
+    }
+  }
+}
diff --git a/src/test/java/com/google/firebase/auth/UserRecordTest.java b/src/test/java/com/google/firebase/auth/UserRecordTest.java
new file mode 100644
index 0000000..a849465
--- /dev/null
+++ b/src/test/java/com/google/firebase/auth/UserRecordTest.java
@@ -0,0 +1,197 @@
+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.fail;
+
+import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.json.JsonFactory;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.auth.internal.DownloadAccountResponse;
+import com.google.firebase.auth.internal.GetAccountInfoResponse;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import org.junit.Test;
+
+public class UserRecordTest {
+
+  private static final JsonFactory JSON_FACTORY = Utils.getDefaultJsonFactory();
+
+  @Test(expected = NullPointerException.class)
+  public void testNullResponse() {
+    new UserRecord(null);
+  }
+
+  @Test
+  public void testNoUid() throws IOException {
+    String json = JSON_FACTORY.toString(ImmutableMap.of());
+    try {
+      parseUser(json);
+      fail("No error thrown for response with no UID");
+    } catch (IllegalArgumentException ignore) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testUserIdOnly() throws IOException {
+    String json = JSON_FACTORY.toString(ImmutableMap.of("localId", "user"));
+    UserRecord userRecord = parseUser(json);
+    assertEquals("user", userRecord.getUid());
+    assertNull(userRecord.getEmail());
+    assertNull(userRecord.getPhoneNumber());
+    assertNull(userRecord.getPhotoUrl());
+    assertNull(userRecord.getDisplayName());
+    assertEquals(0L, userRecord.getUserMetadata().getCreationTimestamp());
+    assertEquals(0L, userRecord.getUserMetadata().getLastSignInTimestamp());
+    assertEquals(0, userRecord.getCustomClaims().size());
+    assertFalse(userRecord.isDisabled());
+    assertFalse(userRecord.isEmailVerified());
+    assertEquals(0, userRecord.getProviderData().length);
+  }
+
+  @Test
+  public void testProviderInfo() throws IOException {
+    ImmutableMap<String, Object> resp = ImmutableMap.<String, Object>of(
+        "localId", "user",
+        "providerUserInfo", ImmutableList.of(
+            ImmutableMap.of("rawId", "provider"),
+            ImmutableMap.of("rawId", "provider")
+        )
+    );
+    String json = JSON_FACTORY.toString(resp);
+    UserRecord userRecord = parseUser(json);
+    assertEquals("user", userRecord.getUid());
+    assertEquals(2, userRecord.getProviderData().length);
+    for (UserInfo provider : userRecord.getProviderData()) {
+      assertEquals("provider", provider.getUid());
+      assertNull(provider.getDisplayName());
+      assertNull(provider.getEmail());
+      assertNull(provider.getPhoneNumber());
+      assertNull(provider.getPhotoUrl());
+      assertNull(provider.getProviderId());
+    }
+  }
+
+  @Test
+  public void testAllProviderInfo() throws IOException {
+    ImmutableMap<String, Object> resp = ImmutableMap.<String, Object>of(
+        "localId", "user",
+        "providerUserInfo", ImmutableList.of(
+            ImmutableMap.builder()
+                .put("rawId", "provider")
+                .put("displayName", "Display Name")
+                .put("email", "email@provider.net")
+                .put("phoneNumber", "1234567890")
+                .put("photoUrl", "http://photo.url")
+                .put("providerId", "providerId")
+                .build()
+            )
+    );
+    String json = JSON_FACTORY.toString(resp);
+    UserRecord userRecord = parseUser(json);
+    assertEquals("user", userRecord.getUid());
+    assertEquals(1, userRecord.getProviderData().length);
+    for (UserInfo provider : userRecord.getProviderData()) {
+      assertEquals("provider", provider.getUid());
+      assertEquals("Display Name", provider.getDisplayName());
+      assertEquals("email@provider.net", provider.getEmail());
+      assertEquals("1234567890", provider.getPhoneNumber());
+      assertEquals("http://photo.url", provider.getPhotoUrl());
+      assertEquals("providerId", provider.getProviderId());
+    }
+  }
+
+  @Test
+  public void testUserMetadata() throws IOException {
+    ImmutableMap<String, Object> resp = ImmutableMap.<String, Object>of(
+        "localId", "user",
+        "createdAt", 10,
+        "lastLoginAt", "20"
+    );
+    String json = JSON_FACTORY.toString(resp);
+    UserRecord userRecord = parseUser(json);
+    assertEquals("user", userRecord.getUid());
+    assertEquals(10L, userRecord.getUserMetadata().getCreationTimestamp());
+    assertEquals(20L, userRecord.getUserMetadata().getLastSignInTimestamp());
+  }
+
+  @Test
+  public void testCustomClaims() throws IOException {
+    ImmutableMap<String, Object> resp = ImmutableMap.<String, Object>of(
+        "localId", "user",
+        "customAttributes", "{\"foo\": \"bar\"}"
+    );
+    String json = JSON_FACTORY.toString(resp);
+    UserRecord userRecord = parseUser(json);
+    assertEquals("user", userRecord.getUid());
+    assertEquals(1, userRecord.getCustomClaims().size());
+    assertEquals("bar", userRecord.getCustomClaims().get("foo"));
+  }
+
+  @Test
+  public void testEmptyCustomClaims() throws IOException {
+    ImmutableMap<String, Object> resp = ImmutableMap.<String, Object>of(
+        "localId", "user",
+        "customAttributes", "{}"
+    );
+    String json = JSON_FACTORY.toString(resp);
+    UserRecord userRecord = parseUser(json);
+    assertEquals("user", userRecord.getUid());
+    assertEquals(0, userRecord.getCustomClaims().size());
+  }
+
+  @Test
+  public void testExportedUserUidOnly() throws IOException {
+    ImmutableMap<String, Object> resp = ImmutableMap.<String, Object>of("localId", "user");
+    String json = JSON_FACTORY.toString(resp);
+    ExportedUserRecord userRecord = parseExportedUser(json);
+    assertEquals("user", userRecord.getUid());
+    assertNull(userRecord.getEmail());
+    assertNull(userRecord.getPhoneNumber());
+    assertNull(userRecord.getPhotoUrl());
+    assertNull(userRecord.getDisplayName());
+    assertEquals(0L, userRecord.getUserMetadata().getCreationTimestamp());
+    assertEquals(0L, userRecord.getUserMetadata().getLastSignInTimestamp());
+    assertEquals(0, userRecord.getCustomClaims().size());
+    assertFalse(userRecord.isDisabled());
+    assertFalse(userRecord.isEmailVerified());
+    assertEquals(0, userRecord.getProviderData().length);
+    assertNull(userRecord.getPasswordHash());
+    assertNull(userRecord.getPasswordSalt());
+  }
+
+  @Test
+  public void testPasswordInfo() throws IOException {
+    ImmutableMap<String, Object> resp = ImmutableMap.<String, Object>of(
+        "localId", "user",
+        "passwordHash", "secret",
+        "salt", "pepper"
+    );
+    String json = JSON_FACTORY.toString(resp);
+    ExportedUserRecord userRecord = parseExportedUser(json);
+    assertEquals("user", userRecord.getUid());
+    assertEquals("secret", userRecord.getPasswordHash());
+    assertEquals("pepper", userRecord.getPasswordSalt());
+  }
+
+  private UserRecord parseUser(String json) throws IOException {
+    InputStream stream = new ByteArrayInputStream(json.getBytes(Charset.defaultCharset()));
+    GetAccountInfoResponse.User user = JSON_FACTORY.createJsonObjectParser()
+        .parseAndClose(stream, Charset.defaultCharset(), GetAccountInfoResponse.User.class);
+    return new UserRecord(user);
+  }
+
+  private ExportedUserRecord parseExportedUser(String json) throws IOException {
+    InputStream stream = new ByteArrayInputStream(json.getBytes(Charset.defaultCharset()));
+    DownloadAccountResponse.User user = JSON_FACTORY.createJsonObjectParser()
+        .parseAndClose(stream, Charset.defaultCharset(),
+            DownloadAccountResponse.User.class);
+    return new ExportedUserRecord(user);
+  }
+}
diff --git a/src/test/resources/getUser.json b/src/test/resources/getUser.json
index 14f5e7c..019c665 100644
--- a/src/test/resources/getUser.json
+++ b/src/test/resources/getUser.json
@@ -23,6 +23,7 @@
     "passwordUpdatedAt" : 1.494364393E+12,
     "validSince" : "1494364393",
     "disabled" : false,
-    "createdAt" : "1234567890"
+    "createdAt" : "1234567890",
+    "customAttributes" : "{\"admin\": true, \"package\": \"gold\"}"
   } ]
 }
diff --git a/src/test/resources/listUsers.json b/src/test/resources/listUsers.json
new file mode 100644
index 0000000..06d0b34
--- /dev/null
+++ b/src/test/resources/listUsers.json
@@ -0,0 +1,55 @@
+{
+  "users" : [ {
+    "localId" : "testuser",
+    "email" : "testuser@example.com",
+    "phoneNumber" : "+1234567890",
+    "emailVerified" : true,
+    "displayName" : "Test User",
+    "providerUserInfo" : [ {
+      "providerId" : "password",
+      "displayName" : "Test User",
+      "photoUrl" : "http://www.example.com/testuser/photo.png",
+      "federatedId" : "testuser@example.com",
+      "email" : "testuser@example.com",
+      "rawId" : "testuser@example.com"
+    }, {
+      "providerId" : "phone",
+      "phoneNumber" : "+1234567890",
+      "rawId" : "+1234567890"
+    } ],
+    "photoUrl" : "http://www.example.com/testuser/photo.png",
+    "passwordHash" : "passwordHash",
+    "salt": "passwordSalt",
+    "passwordUpdatedAt" : 1.494364393E+12,
+    "validSince" : "1494364393",
+    "disabled" : false,
+    "createdAt" : "1234567890",
+    "customAttributes" : "{\"admin\": true, \"package\": \"gold\"}"
+  }, {
+    "localId" : "testuser",
+    "email" : "testuser@example.com",
+    "phoneNumber" : "+1234567890",
+    "emailVerified" : true,
+    "displayName" : "Test User",
+    "providerUserInfo" : [ {
+      "providerId" : "password",
+      "displayName" : "Test User",
+      "photoUrl" : "http://www.example.com/testuser/photo.png",
+      "federatedId" : "testuser@example.com",
+      "email" : "testuser@example.com",
+      "rawId" : "testuser@example.com"
+    }, {
+      "providerId" : "phone",
+      "phoneNumber" : "+1234567890",
+      "rawId" : "+1234567890"
+    } ],
+    "photoUrl" : "http://www.example.com/testuser/photo.png",
+    "passwordHash" : "passwordHash",
+    "salt": "passwordSalt",
+    "passwordUpdatedAt" : 1.494364393E+12,
+    "validSince" : "1494364393",
+    "disabled" : false,
+    "createdAt" : "1234567890",
+    "customAttributes" : "{\"admin\": true, \"package\": \"gold\"}"
+  } ]
+}