Repository: firebase/firebase-admin-java
Pull request #387 (2020-04-07 19:27:48)

Make user operations tenant-aware.
This pull request make user operations tenant-aware. I've added some integration tests to ensure that this is correct correctly. This is part of the initiative to adding multi-tenancy support (see issue #332).

micahstairs: I've fixed the merge conflicts.

Patch:
diff --git a/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java
index 901683c..0c20ec6 100644
--- a/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java
+++ b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java
@@ -59,22 +59,13 @@ public abstract class AbstractFirebaseAuth {
   private final Supplier<? extends FirebaseUserManager> userManager;
   private final JsonFactory jsonFactory;
 
-  AbstractFirebaseAuth(
-      final FirebaseApp firebaseApp,
-      Supplier<FirebaseTokenFactory> tokenFactory,
-      Supplier<? extends FirebaseTokenVerifier> idTokenVerifier,
-      Supplier<? extends FirebaseTokenVerifier> cookieVerifier) {
-    this.firebaseApp = checkNotNull(firebaseApp);
-    this.tokenFactory = threadSafeMemoize(tokenFactory);
-    this.idTokenVerifier = threadSafeMemoize(idTokenVerifier);
-    this.cookieVerifier = threadSafeMemoize(cookieVerifier);
-    this.userManager = threadSafeMemoize(new Supplier<FirebaseUserManager>() {
-      @Override
-      public FirebaseUserManager get() {
-        return new FirebaseUserManager(firebaseApp);
-      }
-    });
-    this.jsonFactory = firebaseApp.getOptions().getJsonFactory();
+  AbstractFirebaseAuth(Builder builder) {
+    this.firebaseApp = checkNotNull(builder.firebaseApp);
+    this.tokenFactory = threadSafeMemoize(builder.tokenFactory);
+    this.idTokenVerifier = threadSafeMemoize(builder.idTokenVerifier);
+    this.cookieVerifier = threadSafeMemoize(builder.cookieVerifier);
+    this.userManager = threadSafeMemoize(builder.userManager);
+    this.jsonFactory = builder.firebaseApp.getOptions().getJsonFactory();
   }
 
   /**
@@ -1101,7 +1092,46 @@ public abstract class AbstractFirebaseAuth {
       destroyed.set(true);
     }
   }
-
+  
   /** Performs any additional required clean up. */
   protected abstract void doDestroy();
+
+  static Builder builder() {
+    return new Builder();
+  }
+
+  static class Builder {
+    protected FirebaseApp firebaseApp;
+    private Supplier<FirebaseTokenFactory> tokenFactory;
+    private Supplier<? extends FirebaseTokenVerifier> idTokenVerifier;
+    private Supplier<? extends FirebaseTokenVerifier> cookieVerifier;
+    private Supplier<FirebaseUserManager> userManager;
+
+    private Builder() {}
+
+    Builder setFirebaseApp(FirebaseApp firebaseApp) {
+      this.firebaseApp = firebaseApp;
+      return this;
+    }
+
+    Builder setTokenFactory(Supplier<FirebaseTokenFactory> tokenFactory) {
+      this.tokenFactory = tokenFactory;
+      return this;
+    }
+
+    Builder setIdTokenVerifier(Supplier<? extends FirebaseTokenVerifier> idTokenVerifier) {
+      this.idTokenVerifier = idTokenVerifier;
+      return this;
+    }
+
+    Builder setCookieVerifier(Supplier<? extends FirebaseTokenVerifier> cookieVerifier) {
+      this.cookieVerifier = cookieVerifier;
+      return this;
+    }
+
+    Builder setUserManager(Supplier<FirebaseUserManager> userManager) {
+      this.userManager = userManager;
+      return this;
+    }
+  }
 }
diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuth.java b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
index 8c7f18d..61205a0 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseAuth.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
@@ -17,10 +17,10 @@
 package com.google.firebase.auth;
 
 import com.google.api.client.util.Clock;
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Supplier;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.ImplFirebaseTrampolines;
+import com.google.firebase.auth.AbstractFirebaseAuth.Builder;
 import com.google.firebase.auth.internal.FirebaseTokenFactory;
 import com.google.firebase.internal.FirebaseService;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -40,12 +40,8 @@ public class FirebaseAuth extends AbstractFirebaseAuth {
   private final Supplier<TenantManager> tenantManager;
   private final AtomicBoolean tenantManagerCreated = new AtomicBoolean(false);
 
-  private FirebaseAuth(final Builder builder) {
-    super(
-        builder.firebaseApp,
-        builder.tokenFactory,
-        builder.idTokenVerifier,
-        builder.cookieVerifier);
+  FirebaseAuth(final Builder builder) {
+    super(builder);
     tenantManager = threadSafeMemoize(new Supplier<TenantManager>() {
       @Override
       public TenantManager get() {
@@ -92,68 +88,37 @@ public class FirebaseAuth extends AbstractFirebaseAuth {
   }
 
   private static FirebaseAuth fromApp(final FirebaseApp app) {
-    return FirebaseAuth.builder()
-        .setFirebaseApp(app)
-        .setTokenFactory(
-            new Supplier<FirebaseTokenFactory>() {
-              @Override
-              public FirebaseTokenFactory get() {
-                return FirebaseTokenUtils.createTokenFactory(app, Clock.SYSTEM);
-              }
+    return new FirebaseAuth(
+        AbstractFirebaseAuth.builder()
+          .setFirebaseApp(app)
+          .setTokenFactory(
+              new Supplier<FirebaseTokenFactory>() {
+                @Override
+                public FirebaseTokenFactory get() {
+                  return FirebaseTokenUtils.createTokenFactory(app, Clock.SYSTEM);
+                }
+              })
+          .setIdTokenVerifier(
+              new Supplier<FirebaseTokenVerifier>() {
+                @Override
+                public FirebaseTokenVerifier get() {
+                  return FirebaseTokenUtils.createIdTokenVerifier(app, Clock.SYSTEM);
+                }
+              })
+          .setCookieVerifier(
+              new Supplier<FirebaseTokenVerifier>() {
+                @Override
+                public FirebaseTokenVerifier get() {
+                  return FirebaseTokenUtils.createSessionCookieVerifier(app, Clock.SYSTEM);
+                }
             })
-        .setIdTokenVerifier(
-            new Supplier<FirebaseTokenVerifier>() {
-              @Override
-              public FirebaseTokenVerifier get() {
-                return FirebaseTokenUtils.createIdTokenVerifier(app, Clock.SYSTEM);
-              }
-            })
-        .setCookieVerifier(
-            new Supplier<FirebaseTokenVerifier>() {
-              @Override
-              public FirebaseTokenVerifier get() {
-                return FirebaseTokenUtils.createSessionCookieVerifier(app, Clock.SYSTEM);
-              }
-            })
-        .build();
-  }
-
-  @VisibleForTesting
-  static Builder builder() {
-    return new Builder();
-  }
-
-  static class Builder {
-    private FirebaseApp firebaseApp;
-    private Supplier<FirebaseTokenFactory> tokenFactory;
-    private Supplier<? extends FirebaseTokenVerifier> idTokenVerifier;
-    private Supplier<? extends FirebaseTokenVerifier> cookieVerifier;
-
-    private Builder() {}
-
-    Builder setFirebaseApp(FirebaseApp firebaseApp) {
-      this.firebaseApp = firebaseApp;
-      return this;
-    }
-
-    Builder setTokenFactory(Supplier<FirebaseTokenFactory> tokenFactory) {
-      this.tokenFactory = tokenFactory;
-      return this;
-    }
-
-    Builder setIdTokenVerifier(Supplier<? extends FirebaseTokenVerifier> idTokenVerifier) {
-      this.idTokenVerifier = idTokenVerifier;
-      return this;
-    }
-
-    Builder setCookieVerifier(Supplier<? extends FirebaseTokenVerifier> cookieVerifier) {
-      this.cookieVerifier = cookieVerifier;
-      return this;
-    }
-
-    FirebaseAuth build() {
-      return new FirebaseAuth(this);
-    }
+          .setUserManager(
+              new Supplier<FirebaseUserManager>() {
+                @Override
+                public FirebaseUserManager get() {
+                  return new FirebaseUserManager(app);
+                }
+              }));
   }
 
   private static class FirebaseAuthService extends FirebaseService<FirebaseAuth> {
diff --git a/src/main/java/com/google/firebase/auth/FirebaseToken.java b/src/main/java/com/google/firebase/auth/FirebaseToken.java
index 3d7b0b2..0cd09a5 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseToken.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseToken.java
@@ -42,6 +42,11 @@ public final class FirebaseToken {
     return (String) claims.get("sub");
   }
 
+  /** Returns the tenant ID for the this token. */
+  public String getTenantId() {
+    return (String) claims.get("tenant_id");
+  }
+
   /** Returns the Issuer for the this token. */
   public String getIssuer() {
     return (String) claims.get("iss");
@@ -57,14 +62,14 @@ public final class FirebaseToken {
     return (String) claims.get("picture");
   }
 
-  /** 
+  /**
    * Returns the e-mail address for this user, or {@code null} if it's unavailable.
    */
   public String getEmail() {
     return (String) claims.get("email");
   }
 
-  /** 
+  /**
    * Indicates if the email address returned by {@link #getEmail()} has been verified as good.
    */
   public boolean isEmailVerified() {
diff --git a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
index 23867e3..c364242 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
@@ -115,21 +115,40 @@ class FirebaseUserManager {
    * Creates a new FirebaseUserManager instance.
    *
    * @param app A non-null {@link FirebaseApp}.
+   * @param tenantId The associated tenant ID if the user operations should be tenant-aware,
+   *     otherwise {@code null}
    */
-  FirebaseUserManager(@NonNull FirebaseApp app) {
+  FirebaseUserManager(@NonNull FirebaseApp app, @Nullable String tenantId) {
     checkNotNull(app, "FirebaseApp must not be null");
     String projectId = ImplFirebaseTrampolines.getProjectId(app);
     checkArgument(!Strings.isNullOrEmpty(projectId),
         "Project ID is required to access the auth service. Use a service account credential or "
             + "set the project ID explicitly via FirebaseOptions. Alternatively you can also "
             + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable.");
-    this.userMgtBaseUrl = String.format(ID_TOOLKIT_URL, "v1", projectId);
+    if (tenantId == null) {
+      this.userMgtBaseUrl = String.format(ID_TOOLKIT_URL, "v1", projectId);
+    } else {
+      checkArgument(!tenantId.isEmpty(), "tenant ID must not be empty");
+      this.userMgtBaseUrl =
+          String.format(ID_TOOLKIT_URL, "v1", projectId) + getTenantUrlSuffix(tenantId);
+    }
     this.tenantMgtBaseUrl = String.format(ID_TOOLKIT_URL, "v2", projectId);
     this.jsonFactory = app.getOptions().getJsonFactory();
     HttpTransport transport = app.getOptions().getHttpTransport();
     this.requestFactory = transport.createRequestFactory(new FirebaseRequestInitializer(app));
   }
 
+  /**
+   * Creates a new FirebaseUserManager instance.
+   *
+   * <p>This convenience constructor is for when user operations should not be tenant-aware.
+   *
+   * @param app A non-null {@link FirebaseApp}.
+   */
+  FirebaseUserManager(@NonNull FirebaseApp app) {
+    this(app, null);
+  }
+
   @VisibleForTesting
   void setInterceptor(HttpResponseInterceptor interceptor) {
     this.interceptor = interceptor;
@@ -230,7 +249,7 @@ class FirebaseUserManager {
   }
 
   Tenant getTenant(String tenantId) throws FirebaseAuthException {
-    GenericUrl url = new GenericUrl(tenantMgtBaseUrl + "/tenants/" + tenantId);
+    GenericUrl url = new GenericUrl(tenantMgtBaseUrl + getTenantUrlSuffix(tenantId));
     return sendRequest("GET", url, null, Tenant.class);
   }
 
@@ -242,7 +261,7 @@ class FirebaseUserManager {
   Tenant updateTenant(Tenant.UpdateRequest request) throws FirebaseAuthException {
     Map<String, Object> properties = request.getProperties();
     checkArgument(!properties.isEmpty(), "tenant update must have at least one property set");
-    GenericUrl url = new GenericUrl(tenantMgtBaseUrl + "/tenants/" + request.getTenantId());
+    GenericUrl url = new GenericUrl(tenantMgtBaseUrl + getTenantUrlSuffix(request.getTenantId()));
     url.put("updateMask", generateMask(properties));
     return sendRequest("PATCH", url, properties, Tenant.class);
   }
@@ -256,7 +275,7 @@ class FirebaseUserManager {
   }
 
   void deleteTenant(String tenantId) throws FirebaseAuthException {
-    GenericUrl url = new GenericUrl(tenantMgtBaseUrl + "/tenants/" + tenantId);
+    GenericUrl url = new GenericUrl(tenantMgtBaseUrl + getTenantUrlSuffix(tenantId));
     sendRequest("DELETE", url, null, GenericJson.class);
   }
 
@@ -312,6 +331,11 @@ class FirebaseUserManager {
     throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to create email action link");
   }
 
+  private static String getTenantUrlSuffix(String tenantId) {
+    checkArgument(!Strings.isNullOrEmpty(tenantId));
+    return "/tenants/" + tenantId;
+  }
+
   private <T> T post(String path, Object content, Class<T> clazz) throws FirebaseAuthException {
     checkArgument(!Strings.isNullOrEmpty(path), "path must not be null or empty");
     checkNotNull(content, "content must not be null for POST requests");
diff --git a/src/main/java/com/google/firebase/auth/TenantAwareFirebaseAuth.java b/src/main/java/com/google/firebase/auth/TenantAwareFirebaseAuth.java
new file mode 100644
index 0000000..062b071
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/TenantAwareFirebaseAuth.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * 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.api.client.util.Clock;
+import com.google.common.base.Strings;
+import com.google.common.base.Supplier;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.auth.internal.FirebaseTokenFactory;
+
+/**
+ * The tenant-aware Firebase client.
+ *
+ * <p>This can be used to perform a variety of authentication-related operations, scoped to a
+ * particular tenant.
+ */
+public class TenantAwareFirebaseAuth extends AbstractFirebaseAuth {
+
+  private final String tenantId;
+
+  TenantAwareFirebaseAuth(final FirebaseApp firebaseApp, final String tenantId) {
+    super(builderFromAppAndTenantId(firebaseApp, tenantId));
+    checkArgument(!Strings.isNullOrEmpty(tenantId));
+    this.tenantId = tenantId;
+  }
+
+  private static Builder builderFromAppAndTenantId(final FirebaseApp app, final String tenantId) {
+    // TODO(micahstairs): Incorporate tenant ID into token generation as well as ID token and
+    // session cookie verification.
+    return AbstractFirebaseAuth.builder()
+        .setFirebaseApp(app)
+        .setTokenFactory(
+            new Supplier<FirebaseTokenFactory>() {
+              @Override
+              public FirebaseTokenFactory get() {
+                return FirebaseTokenUtils.createTokenFactory(app, Clock.SYSTEM);
+              }
+            })
+        .setIdTokenVerifier(
+            new Supplier<FirebaseTokenVerifier>() {
+              @Override
+              public FirebaseTokenVerifier get() {
+                return FirebaseTokenUtils.createIdTokenVerifier(app, Clock.SYSTEM);
+              }
+            })
+        .setCookieVerifier(
+            new Supplier<FirebaseTokenVerifier>() {
+              @Override
+              public FirebaseTokenVerifier get() {
+                return FirebaseTokenUtils.createSessionCookieVerifier(app, Clock.SYSTEM);
+              }
+            })
+        .setUserManager(
+            new Supplier<FirebaseUserManager>() {
+              @Override
+              public FirebaseUserManager get() {
+                return new FirebaseUserManager(app, tenantId);
+              }
+            });
+  }
+
+  /** Returns the corresponding tenant ID. */
+  public String getTenantId() {
+    return tenantId;
+  }
+
+  @Override
+  protected void doDestroy() {
+    // Nothing extra needs to be destroyed.
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/TenantManager.java b/src/main/java/com/google/firebase/auth/TenantManager.java
index f4e1c8e..0b61755 100644
--- a/src/main/java/com/google/firebase/auth/TenantManager.java
+++ b/src/main/java/com/google/firebase/auth/TenantManager.java
@@ -32,13 +32,13 @@ import com.google.firebase.auth.Tenant.UpdateRequest;
 import com.google.firebase.internal.CallableOperation;
 import com.google.firebase.internal.NonNull;
 import com.google.firebase.internal.Nullable;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
  * This class can be used to perform a variety of tenant-related operations, including creating,
  * updating, and listing tenants.
- *
- * <p>TODO(micahstairs): Implement getAuthForTenant().
  */
 public final class TenantManager {
 
@@ -47,10 +47,12 @@ public final class TenantManager {
 
   private final FirebaseApp firebaseApp;
   private final FirebaseUserManager userManager;
+  private final Map<String, TenantAwareFirebaseAuth> tenantAwareAuths;
 
   TenantManager(FirebaseApp firebaseApp, FirebaseUserManager userManager) {
     this.firebaseApp = firebaseApp;
     this.userManager = userManager;
+    tenantAwareAuths = new HashMap<String, TenantAwareFirebaseAuth>();
   }
 
   /**
@@ -65,6 +67,14 @@ public final class TenantManager {
     return getTenantOp(tenantId).call();
   }
 
+  public synchronized TenantAwareFirebaseAuth getAuthForTenant(@NonNull String tenantId) {
+    checkArgument(!Strings.isNullOrEmpty(tenantId), "tenantId must not be null or empty");
+    if (!tenantAwareAuths.containsKey(tenantId)) {
+      tenantAwareAuths.put(tenantId, new TenantAwareFirebaseAuth(firebaseApp, tenantId));
+    }
+    return tenantAwareAuths.get(tenantId);
+  }
+
   /**
    * Similar to {@link #getTenant(String)} but performs the operation asynchronously.
    *
diff --git a/src/main/java/com/google/firebase/auth/UserRecord.java b/src/main/java/com/google/firebase/auth/UserRecord.java
index 1e199e0..e8896de 100644
--- a/src/main/java/com/google/firebase/auth/UserRecord.java
+++ b/src/main/java/com/google/firebase/auth/UserRecord.java
@@ -49,6 +49,7 @@ public class UserRecord implements UserInfo {
   private static final int MAX_CLAIMS_PAYLOAD_SIZE = 1000;
 
   private final String uid;
+  private final String tenantId;
   private final String email;
   private final String phoneNumber;
   private final boolean emailVerified;
@@ -65,6 +66,7 @@ public class UserRecord implements UserInfo {
     checkNotNull(jsonFactory, "jsonFactory must not be null");
     checkArgument(!Strings.isNullOrEmpty(response.getUid()), "uid must not be null or empty");
     this.uid = response.getUid();
+    this.tenantId = response.getTenantId();
     this.email = response.getEmail();
     this.phoneNumber = response.getPhoneNumber();
     this.emailVerified = response.isEmailVerified();
@@ -107,6 +109,16 @@ public class UserRecord implements UserInfo {
     return uid;
   }
 
+  /**
+   * Returns the tenant ID associated with this user, if one exists.
+   *
+   * @return a tenant ID string or null.
+   */
+  @Nullable
+  public String getTenantId() {
+    return this.tenantId;
+  }
+
   /**
    * Returns the provider ID of this user.
    *
@@ -190,9 +202,9 @@ public class UserRecord implements UserInfo {
   }
 
   /**
-   * Returns a timestamp in milliseconds since epoch, truncated down to the closest second. 
+   * Returns a timestamp in milliseconds since epoch, truncated down to the closest second.
    * Tokens minted before this timestamp are considered invalid.
-   * 
+   *
    * @return Timestamp in milliseconds since the epoch. Tokens minted before this timestamp are
    *     considered invalid.
    */
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..0c7a92e 100644
--- a/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java
+++ b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java
@@ -46,6 +46,9 @@ public final class GetAccountInfoResponse {
     @Key("localId")
     private String uid;
 
+    @Key("tenantId")
+    private String tenantId;
+
     @Key("email")
     private String email;
 
@@ -83,6 +86,10 @@ public final class GetAccountInfoResponse {
       return uid;
     }
 
+    public String getTenantId() {
+      return tenantId;
+    }
+
     public String getEmail() {
       return email;
     }
@@ -122,7 +129,7 @@ public final class GetAccountInfoResponse {
     public long getValidSince() {
       return validSince;
     }
-    
+
     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 d6a41d4..c45c228 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
@@ -181,6 +181,7 @@ public class FirebaseAuthIT {
     // Get user
     userRecord = auth.getUserAsync(userRecord.getUid()).get();
     assertEquals(uid, userRecord.getUid());
+    assertNull(userRecord.getTenantId());
     assertNull(userRecord.getDisplayName());
     assertNull(userRecord.getEmail());
     assertNull(userRecord.getPhoneNumber());
@@ -204,6 +205,7 @@ public class FirebaseAuthIT {
         .setPassword("secret");
     userRecord = auth.updateUserAsync(request).get();
     assertEquals(uid, userRecord.getUid());
+    assertNull(userRecord.getTenantId());
     assertEquals("Updated Name", userRecord.getDisplayName());
     assertEquals(randomUser.email, userRecord.getEmail());
     assertEquals(phone, userRecord.getPhoneNumber());
@@ -225,6 +227,7 @@ public class FirebaseAuthIT {
         .setDisabled(true);
     userRecord = auth.updateUserAsync(request).get();
     assertEquals(uid, userRecord.getUid());
+    assertNull(userRecord.getTenantId());
     assertNull(userRecord.getDisplayName());
     assertEquals(randomUser.email, userRecord.getEmail());
     assertNull(userRecord.getPhoneNumber());
@@ -236,14 +239,7 @@ public class FirebaseAuthIT {
 
     // Delete user
     auth.deleteUserAsync(userRecord.getUid()).get();
-    try {
-      auth.getUserAsync(userRecord.getUid()).get();
-      fail("No error thrown for getting a deleted user");
-    } catch (ExecutionException e) {
-      assertTrue(e.getCause() instanceof FirebaseAuthException);
-      assertEquals(FirebaseUserManager.USER_NOT_FOUND_ERROR,
-          ((FirebaseAuthException) e.getCause()).getErrorCode());
-    }
+    assertUserDoesNotExist(auth, userRecord.getUid());
   }
 
   @Test
@@ -268,6 +264,7 @@ public class FirebaseAuthIT {
                 + "forgetting to add the \"Firebase Authentication Admin\" permission. See "
                 + "instructions in CONTRIBUTING.md", user.getPasswordHash());
             assertNotNull(user.getPasswordSalt());
+            assertNull(user.getTenantId());
           }
         }
         page = page.getNextPage();
@@ -282,6 +279,7 @@ public class FirebaseAuthIT {
           collected.incrementAndGet();
           assertNotNull(user.getPasswordHash());
           assertNotNull(user.getPasswordSalt());
+          assertNull(user.getTenantId());
         }
       }
       assertEquals(uids.size(), collected.get());
@@ -305,6 +303,7 @@ public class FirebaseAuthIT {
               collected.incrementAndGet();
               assertNotNull(user.getPasswordHash());
               assertNotNull(user.getPasswordSalt());
+              assertNull(user.getTenantId());
             }
           }
           semaphore.release();
@@ -320,6 +319,209 @@ public class FirebaseAuthIT {
     }
   }
 
+  @Test
+  public void testTenantAwareUserLifecycle() throws Exception {
+    // Create tenant to use.
+    TenantManager tenantManager = auth.getTenantManager();
+    Tenant.CreateRequest tenantCreateRequest =
+        new Tenant.CreateRequest().setDisplayName("DisplayName");
+    final String tenantId = tenantManager.createTenant(tenantCreateRequest).getTenantId();
+
+    TenantAwareFirebaseAuth tenantAwareAuth = auth.getTenantManager().getAuthForTenant(tenantId);
+
+    // Create user
+    UserRecord userRecord = tenantAwareAuth.createUserAsync(new UserRecord.CreateRequest()).get();
+    String uid = userRecord.getUid();
+
+    // Get user
+    userRecord = tenantAwareAuth.getUserAsync(userRecord.getUid()).get();
+    assertEquals(uid, userRecord.getUid());
+    assertEquals(tenantId, userRecord.getTenantId());
+    assertNull(userRecord.getDisplayName());
+    assertNull(userRecord.getEmail());
+    assertNull(userRecord.getPhoneNumber());
+    assertNull(userRecord.getPhotoUrl());
+    assertFalse(userRecord.isEmailVerified());
+    assertFalse(userRecord.isDisabled());
+    assertTrue(userRecord.getUserMetadata().getCreationTimestamp() > 0);
+    assertEquals(0, userRecord.getUserMetadata().getLastSignInTimestamp());
+    assertEquals(0, userRecord.getProviderData().length);
+    assertTrue(userRecord.getCustomClaims().isEmpty());
+
+    // Update user
+    RandomUser randomUser = RandomUser.create();
+    String phone = randomPhoneNumber();
+    UserRecord.UpdateRequest request = userRecord.updateRequest()
+        .setDisplayName("Updated Name")
+        .setEmail(randomUser.email)
+        .setPhoneNumber(phone)
+        .setPhotoUrl("https://example.com/photo.png")
+        .setEmailVerified(true)
+        .setPassword("secret");
+    userRecord = tenantAwareAuth.updateUserAsync(request).get();
+    assertEquals(uid, userRecord.getUid());
+    assertEquals(tenantId, userRecord.getTenantId());
+    assertEquals("Updated Name", userRecord.getDisplayName());
+    assertEquals(randomUser.email, userRecord.getEmail());
+    assertEquals(phone, userRecord.getPhoneNumber());
+    assertEquals("https://example.com/photo.png", userRecord.getPhotoUrl());
+    assertTrue(userRecord.isEmailVerified());
+    assertFalse(userRecord.isDisabled());
+    assertEquals(2, userRecord.getProviderData().length);
+    assertTrue(userRecord.getCustomClaims().isEmpty());
+
+    // Get user by email
+    userRecord = tenantAwareAuth.getUserByEmailAsync(userRecord.getEmail()).get();
+    assertEquals(uid, userRecord.getUid());
+
+    // Disable user and remove properties
+    request = userRecord.updateRequest()
+        .setPhotoUrl(null)
+        .setDisplayName(null)
+        .setPhoneNumber(null)
+        .setDisabled(true);
+    userRecord = tenantAwareAuth.updateUserAsync(request).get();
+    assertEquals(uid, userRecord.getUid());
+    assertEquals(tenantId, userRecord.getTenantId());
+    assertNull(userRecord.getDisplayName());
+    assertEquals(randomUser.email, userRecord.getEmail());
+    assertNull(userRecord.getPhoneNumber());
+    assertNull(userRecord.getPhotoUrl());
+    assertTrue(userRecord.isEmailVerified());
+    assertTrue(userRecord.isDisabled());
+    assertEquals(1, userRecord.getProviderData().length);
+    assertTrue(userRecord.getCustomClaims().isEmpty());
+
+    // Delete user and tenant
+    tenantAwareAuth.deleteUserAsync(userRecord.getUid()).get();
+    assertUserDoesNotExist(tenantAwareAuth, userRecord.getUid());
+    tenantManager.deleteTenant(tenantId);
+  }
+
+  @Test
+  public void testTenantAwareListUsers() throws Exception {
+    // Create tenant to use.
+    TenantManager tenantManager = auth.getTenantManager();
+    Tenant.CreateRequest tenantCreateRequest =
+        new Tenant.CreateRequest().setDisplayName("DisplayName");
+    final String tenantId = tenantManager.createTenant(tenantCreateRequest).getTenantId();
+
+    TenantAwareFirebaseAuth tenantAwareAuth = tenantManager.getAuthForTenant(tenantId);
+    final List<String> uids = new ArrayList<>();
+
+    try {
+      for (int i = 0; i < 3; i++) {
+        UserRecord.CreateRequest createRequest =
+            new UserRecord.CreateRequest().setPassword("password");
+        uids.add(tenantAwareAuth.createUserAsync(createRequest).get().getUid());
+      }
+
+      // Test list by batches
+      final AtomicInteger collected = new AtomicInteger(0);
+      ListUsersPage page = tenantAwareAuth.listUsersAsync(null).get();
+      while (page != null) {
+        for (ExportedUserRecord user : page.getValues()) {
+          if (uids.contains(user.getUid())) {
+            collected.incrementAndGet();
+            assertNotNull("Missing passwordHash field. A common cause would be "
+                + "forgetting to add the \"Firebase Authentication Admin\" permission. See "
+                + "instructions in CONTRIBUTING.md", user.getPasswordHash());
+            assertNotNull(user.getPasswordSalt());
+            assertEquals(tenantId, user.getTenantId());
+          }
+        }
+        page = page.getNextPage();
+      }
+      assertEquals(uids.size(), collected.get());
+
+      // Test iterate all
+      collected.set(0);
+      page = tenantAwareAuth.listUsersAsync(null).get();
+      for (ExportedUserRecord user : page.iterateAll()) {
+        if (uids.contains(user.getUid())) {
+          collected.incrementAndGet();
+          assertNotNull(user.getPasswordHash());
+          assertNotNull(user.getPasswordSalt());
+          assertEquals(tenantId, user.getTenantId());
+        }
+      }
+      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 = tenantAwareAuth.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());
+              assertEquals(tenantId, user.getTenantId());
+            }
+          }
+          semaphore.release();
+        }
+      }, MoreExecutors.directExecutor());
+      semaphore.acquire();
+      assertEquals(uids.size(), collected.get());
+      assertNull(error.get());
+    } finally {
+      for (String uid : uids) {
+        tenantAwareAuth.deleteUserAsync(uid).get();
+      }
+      tenantManager.deleteTenant(tenantId);
+    }
+  }
+
+  @Test
+  public void testTenantAwareGetUserWithMultipleTenantIds() throws Exception {
+    // Create tenants to use.
+    TenantManager tenantManager = auth.getTenantManager();
+    Tenant.CreateRequest tenantCreateRequest1 =
+        new Tenant.CreateRequest().setDisplayName("DisplayName1");
+    String tenantId1 = tenantManager.createTenant(tenantCreateRequest1).getTenantId();
+    Tenant.CreateRequest tenantCreateRequest2 =
+        new Tenant.CreateRequest().setDisplayName("DisplayName2");
+    String tenantId2 = tenantManager.createTenant(tenantCreateRequest2).getTenantId();
+
+    // Create three users (one without a tenant ID, and two with different tenant IDs).
+    UserRecord.CreateRequest createRequest = new UserRecord.CreateRequest();
+    UserRecord nonTenantUserRecord = auth.createUser(createRequest);
+    TenantAwareFirebaseAuth tenantAwareAuth1 = auth.getTenantManager().getAuthForTenant(tenantId1);
+    UserRecord tenantUserRecord1 = tenantAwareAuth1.createUser(createRequest);
+    TenantAwareFirebaseAuth tenantAwareAuth2 = auth.getTenantManager().getAuthForTenant(tenantId2);
+    UserRecord tenantUserRecord2 = tenantAwareAuth2.createUser(createRequest);
+
+    // Make sure only non-tenant users can be fetched using the standard client.
+    assertNotNull(auth.getUser(nonTenantUserRecord.getUid()));
+    assertUserDoesNotExist(auth, tenantUserRecord1.getUid());
+    assertUserDoesNotExist(auth, tenantUserRecord2.getUid());
+
+    // Make sure tenant-aware client cannot fetch users outside that tenant.
+    assertUserDoesNotExist(tenantAwareAuth1, nonTenantUserRecord.getUid());
+    assertUserDoesNotExist(tenantAwareAuth1, tenantUserRecord2.getUid());
+    assertUserDoesNotExist(tenantAwareAuth2, nonTenantUserRecord.getUid());
+    assertUserDoesNotExist(tenantAwareAuth2, tenantUserRecord1.getUid());
+
+    // Make sure tenant-aware client can fetch users under that tenant.
+    assertNotNull(tenantAwareAuth1.getUser(tenantUserRecord1.getUid()));
+    assertNotNull(tenantAwareAuth2.getUser(tenantUserRecord2.getUid()));
+
+    // Delete tenants.
+    tenantManager.deleteTenant(tenantId1);
+    tenantManager.deleteTenant(tenantId2);
+  }
+
   @Test
   public void testTenantLifecycle() throws Exception {
     TenantManager tenantManager = auth.getTenantManager();
@@ -824,4 +1026,16 @@ public class FirebaseAuthIT {
       return new RandomUser(uid, email);
     }
   }
+
+  private static void assertUserDoesNotExist(AbstractFirebaseAuth firebaseAuth, String uid)
+      throws Exception {
+    try {
+      firebaseAuth.getUserAsync(uid).get();
+      fail("No error thrown for getting a user which was expected to be absent");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseAuthException);
+      assertEquals(FirebaseUserManager.USER_NOT_FOUND_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 cf3b683..dedc70e 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
@@ -429,12 +429,13 @@ public class FirebaseAuthTest {
   private FirebaseAuth getAuthForIdTokenVerification(
       Supplier<? extends FirebaseTokenVerifier> tokenVerifierSupplier) {
     FirebaseApp app = FirebaseApp.initializeApp(firebaseOptions);
-    return FirebaseAuth.builder()
-        .setFirebaseApp(app)
-        .setTokenFactory(Suppliers.<FirebaseTokenFactory>ofInstance(null))
-        .setIdTokenVerifier(tokenVerifierSupplier)
-        .setCookieVerifier(Suppliers.<FirebaseTokenVerifier>ofInstance(null))
-        .build();
+    return new FirebaseAuth(
+        AbstractFirebaseAuth.builder()
+          .setFirebaseApp(app)
+          .setTokenFactory(Suppliers.<FirebaseTokenFactory>ofInstance(null))
+          .setIdTokenVerifier(tokenVerifierSupplier)
+          .setCookieVerifier(Suppliers.<FirebaseTokenVerifier>ofInstance(null))
+          .setUserManager(Suppliers.<FirebaseUserManager>ofInstance(new FirebaseUserManager(app))));
   }
 
   private FirebaseAuth getAuthForSessionCookieVerification(FirebaseTokenVerifier tokenVerifier) {
@@ -444,12 +445,13 @@ public class FirebaseAuthTest {
   private FirebaseAuth getAuthForSessionCookieVerification(
       Supplier<? extends FirebaseTokenVerifier> tokenVerifierSupplier) {
     FirebaseApp app = FirebaseApp.initializeApp(firebaseOptions);
-    return FirebaseAuth.builder()
-        .setFirebaseApp(app)
-        .setTokenFactory(Suppliers.<FirebaseTokenFactory>ofInstance(null))
-        .setIdTokenVerifier(Suppliers.<FirebaseTokenVerifier>ofInstance(null))
-        .setCookieVerifier(tokenVerifierSupplier)
-        .build();
+    return new FirebaseAuth(
+        AbstractFirebaseAuth.builder()
+          .setFirebaseApp(app)
+          .setTokenFactory(Suppliers.<FirebaseTokenFactory>ofInstance(null))
+          .setIdTokenVerifier(Suppliers.<FirebaseTokenVerifier>ofInstance(null))
+          .setCookieVerifier(tokenVerifierSupplier)
+          .setUserManager(Suppliers.<FirebaseUserManager>ofInstance(new FirebaseUserManager(app))));
   }
 
   private static class MockTokenVerifier implements FirebaseTokenVerifier {
diff --git a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
index a9b8881..3197c5b 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
@@ -1430,6 +1430,7 @@ public class FirebaseUserManagerTest {
     assertFalse(userRecord.isDisabled());
     assertTrue(userRecord.isEmailVerified());
     assertEquals(1494364393000L, userRecord.getTokensValidAfterTimestamp());
+    assertEquals("testTenant", userRecord.getTenantId());
 
     UserInfo provider = userRecord.getProviderData()[0];
     assertEquals("testuser@example.com", provider.getUid());
diff --git a/src/test/resources/getUser.json b/src/test/resources/getUser.json
index 019c665..3d57f10 100644
--- a/src/test/resources/getUser.json
+++ b/src/test/resources/getUser.json
@@ -24,6 +24,7 @@
     "validSince" : "1494364393",
     "disabled" : false,
     "createdAt" : "1234567890",
-    "customAttributes" : "{\"admin\": true, \"package\": \"gold\"}"
+    "customAttributes" : "{\"admin\": true, \"package\": \"gold\"}",
+    "tenantId": "testTenant"
   } ]
 }
diff --git a/src/test/resources/listUsers.json b/src/test/resources/listUsers.json
index 06d0b34..47e1697 100644
--- a/src/test/resources/listUsers.json
+++ b/src/test/resources/listUsers.json
@@ -24,7 +24,8 @@
     "validSince" : "1494364393",
     "disabled" : false,
     "createdAt" : "1234567890",
-    "customAttributes" : "{\"admin\": true, \"package\": \"gold\"}"
+    "customAttributes" : "{\"admin\": true, \"package\": \"gold\"}",
+    "tenantId": "testTenant"
   }, {
     "localId" : "testuser",
     "email" : "testuser@example.com",
@@ -50,6 +51,7 @@
     "validSince" : "1494364393",
     "disabled" : false,
     "createdAt" : "1234567890",
-    "customAttributes" : "{\"admin\": true, \"package\": \"gold\"}"
+    "customAttributes" : "{\"admin\": true, \"package\": \"gold\"}",
+    "tenantId": "testTenant"
   } ]
 }