Repository: firebase/firebase-admin-java
Pull request #232 (2018-12-05 19:27:43)

FirebaseAuth Email Action Links API
go/email-action-links

Adds following new methods to `FirebaseAuth` API:

```
String generatePasswordResetLink(String email)
String generatePasswordResetLink(String email, ActionCodeSettings settings)
String generateEmailVerificationLink(String email)
String generateEmailVerificationLink(String email, ActionCodeSettings settings)
String generateSignInWithEmailLink(String email, ActionCodeSettings settings)
ApiFuture<String> generatePasswordResetLinkAsync(String email)
ApiFuture<String> generatePasswordResetLinkAsync(String email, ActionCodeSettings settings)
ApiFuture<String> generateEmailVerificationLinkAsync(String email)
ApiFuture<String> generateEmailVerificationLinkAsync(String email, ActionCodeSettings settings)
ApiFuture<String> generateSignInWithEmailLinkAsync(String email, ActionCodeSettings settings)
```


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d59a9fb..0f9deac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,14 @@
 # Unreleased
 
+- [added] Added `generatePasswordResetLink()`, `generateEmailVerificationLink()`
+  and `generateSignInWithEmailLink()` methods to the `FirebaseAuth` API.
 - `Aps` class now supports configuring a critical sound. A new
   `CriticalSound` class has been introduced for this purpose.
 - [fixed] `Firestore` instances initialized by the SDK are now cleaned
   up, when `FirebaseApp.delete()` is called.
+- [added] Added new `setChannelId()` method to the
+  `AndroidNotification.Builder` API for setting the Android
+  notification channel ID (new in Android O).
 
 # v6.6.0
 
@@ -15,10 +20,6 @@
 - [fixed] FCM errors sent by the back-end now include more details
   that are helpful when debugging problems.
 - [changed] Migrated the `FirebaseAuth` user management API to the
-  new Identity Toolkit endpoint.
-- [added] Added new `setChannelId()` to the 
-  AndroidNotification.Builder API for setting the Android 
-  notification channel ID (new in Android O).
 
 # v6.5.0
 
diff --git a/src/main/java/com/google/firebase/auth/ActionCodeSettings.java b/src/main/java/com/google/firebase/auth/ActionCodeSettings.java
new file mode 100644
index 0000000..67a196e
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/ActionCodeSettings.java
@@ -0,0 +1,199 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.auth;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+
+import com.google.firebase.internal.NonNull;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Map;
+
+/**
+ * Defines the required continue/state URL with optional Android and iOS settings. Used when
+ * invoking the email action link generation APIs in {@link FirebaseAuth}.
+ */
+public final class ActionCodeSettings {
+
+  private final Map<String, Object> properties;
+
+  private ActionCodeSettings(Builder builder) {
+    checkArgument(!Strings.isNullOrEmpty(builder.url), "URL must not be null or empty");
+    try {
+      new URL(builder.url);
+    } catch (MalformedURLException e) {
+      throw new IllegalArgumentException("Malformed URL string", e);
+    }
+    if (builder.androidInstallApp || !Strings.isNullOrEmpty(builder.androidMinimumVersion)) {
+      checkArgument(!Strings.isNullOrEmpty(builder.androidPackageName),
+          "Android package name is required when specifying other Android settings");
+    }
+    ImmutableMap.Builder<String, Object> properties = ImmutableMap.<String, Object>builder()
+            .put("continueUrl", builder.url)
+            .put("canHandleCodeInApp", builder.handleCodeInApp);
+    if (!Strings.isNullOrEmpty(builder.dynamicLinkDomain)) {
+      properties.put("dynamicLinkDomain", builder.dynamicLinkDomain);
+    }
+    if (!Strings.isNullOrEmpty(builder.iosBundleId)) {
+      properties.put("iOSBundleId", builder.iosBundleId);
+    }
+    if (!Strings.isNullOrEmpty(builder.androidPackageName)) {
+      properties.put("androidPackageName", builder.androidPackageName);
+      if (!Strings.isNullOrEmpty(builder.androidMinimumVersion)) {
+        properties.put("androidMinimumVersion", builder.androidMinimumVersion);
+      }
+      if (builder.androidInstallApp) {
+        properties.put("androidInstallApp", builder.androidInstallApp);
+      }
+    }
+    this.properties = properties.build();
+  }
+
+  Map<String, Object> getProperties() {
+    return this.properties;
+  }
+
+  /**
+   * Creates a new {@link ActionCodeSettings.Builder}.
+   *
+   * @return A {@link ActionCodeSettings.Builder} instance.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static final class Builder {
+
+    private String url;
+    private boolean handleCodeInApp;
+    private String dynamicLinkDomain;
+    private String iosBundleId;
+    private String androidPackageName;
+    private String androidMinimumVersion;
+    private boolean androidInstallApp;
+
+    private Builder() { }
+
+    /**
+     * Sets the link continue/state URL, which has different meanings in different contexts:
+     *
+     * <ul>
+     * <li>When the link is handled in the web action widgets, this is the deep link in the
+     * {@code continueUrl} query parameter.</li>
+     * <li>When the link is handled in the app directly, this is the {@code continueUrl} query
+     * parameter in the deep link of the Dynamic Link.</li>
+     * </ul>
+     *
+     * <p>This parameter must be specified when creating a new {@link ActionCodeSettings} instance.
+     *
+     * @param url Continue/state URL string.
+     * @return This builder.
+     */
+    public Builder setUrl(@NonNull String url) {
+      this.url = url;
+      return this;
+    }
+
+    /**
+     * Specifies whether to open the link via a mobile app or a browser. The default is false.
+     * When set to true, the action code link is sent as a Universal Link or an Android App Link
+     * and is opened by the app if installed. In the false case, the code is sent to the web widget
+     * first and then redirects to the app if installed.
+     *
+     * @param handleCodeInApp true to open the link in the app, and false otherwise.
+     * @return This builder.
+     */
+    public Builder setHandleCodeInApp(boolean handleCodeInApp) {
+      this.handleCodeInApp = handleCodeInApp;
+      return this;
+    }
+
+    /**
+     * Sets the dynamic link domain to use for the current link if it is to be opened using
+     * Firebase Dynamic Links, as multiple dynamic link domains can be configured per project. This
+     * setting provides the ability to explicitly choose one. If none is provided, the oldest
+     * domain is used by default.
+     *
+     * @param dynamicLinkDomain Firebase Dynamic Link domain string.
+     * @return This builder.
+     */
+    public Builder setDynamicLinkDomain(String dynamicLinkDomain) {
+      this.dynamicLinkDomain = dynamicLinkDomain;
+      return this;
+    }
+
+    /**
+     * Sets the bundle ID of the iOS app where the link should be handled if the
+     * application is already installed on the device.
+     *
+     * @param iosBundleId The iOS bundle ID string.
+     */
+    public Builder setIosBundleId(String iosBundleId) {
+      this.iosBundleId = iosBundleId;
+      return this;
+    }
+
+    /**
+     * Sets the Android package name of the app where the link should be handled if the
+     * Android app is installed. Must be specified when setting other Android-specific settings.
+     *
+     * @param androidPackageName Package name string. Must be specified, and must not be null
+     *     or empty.
+     * @return This builder.
+     */
+    public Builder setAndroidPackageName(String androidPackageName) {
+      this.androidPackageName = androidPackageName;
+      return this;
+    }
+
+    /**
+     * Sets the minimum version for Android app. If the installed app is an older version, the user
+     * is taken to the Play Store to upgrade the app.
+     *
+     * @param androidMinimumVersion Minimum version string.
+     * @return This builder.
+     */
+    public Builder setAndroidMinimumVersion(String androidMinimumVersion) {
+      this.androidMinimumVersion = androidMinimumVersion;
+      return this;
+    }
+
+    /**
+     * Specifies whether to install the Android app if the device supports it and the app is not
+     * already installed.
+     *
+     * @param androidInstallApp true to install the app, and false otherwise.
+     * @return This builder.
+     */
+    public Builder setAndroidInstallApp(boolean androidInstallApp) {
+      this.androidInstallApp = androidInstallApp;
+      return this;
+    }
+
+    /**
+     * Builds a new {@link ActionCodeSettings}.
+     *
+     * @return A non-null {@link ActionCodeSettings}.
+     */
+    public ActionCodeSettings build() {
+      return new ActionCodeSettings(this);
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuth.java b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
index eb25895..ec1d622 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseAuth.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
@@ -27,6 +27,7 @@ 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.FirebaseUserManager.EmailLinkType;
 import com.google.firebase.auth.FirebaseUserManager.UserImportRequest;
 import com.google.firebase.auth.ListUsersPage.DefaultUserSource;
 import com.google.firebase.auth.ListUsersPage.PageFactory;
@@ -969,6 +970,181 @@ public class FirebaseAuth {
     };
   }
 
+  /**
+   * Generates the out-of-band email action link for password reset flows for the specified email
+   * address.
+   *
+   * @param email The email of the user whose password is to be reset.
+   * @return A password reset link.
+   * @throws IllegalArgumentException If the email address is null or empty.
+   * @throws FirebaseAuthException If an error occurs while generating the link.
+   */
+  public String generatePasswordResetLink(@NonNull String email) throws FirebaseAuthException {
+    return generatePasswordResetLink(email, null);
+  }
+
+  /**
+   * Generates the out-of-band email action link for password reset flows for the specified email
+   * address.
+   *
+   * @param email The email of the user whose password is to be reset.
+   * @param settings The action code settings which defines whether
+   *     the link is to be handled by a mobile app and the additional state information to be
+   *     passed in the deep link, etc.
+   * @return A password reset link.
+   * @throws IllegalArgumentException If the email address is null or empty.
+   * @throws FirebaseAuthException If an error occurs while generating the link.
+   */
+  public String generatePasswordResetLink(
+      @NonNull String email, @Nullable ActionCodeSettings settings) throws FirebaseAuthException {
+    return generateEmailActionLinkOp(EmailLinkType.PASSWORD_RESET, email, settings).call();
+  }
+
+  /**
+   * Similar to {@link #generatePasswordResetLink(String)} but performs the operation
+   * asynchronously.
+   *
+   * @param email The email of the user whose password is to be reset.
+   * @return An {@code ApiFuture} which will complete successfully with the generated email action
+   *     link. If an error occurs while generating the link, the future throws a
+   *     {@link FirebaseAuthException}.
+   * @throws IllegalArgumentException If the email address is null or empty.
+   */
+  public ApiFuture<String> generatePasswordResetLinkAsync(@NonNull String email) {
+    return generatePasswordResetLinkAsync(email, null);
+  }
+
+  /**
+   * Similar to {@link #generatePasswordResetLink(String, ActionCodeSettings)} but performs the
+   * operation asynchronously.
+   *
+   * @param email The email of the user whose password is to be reset.
+   * @param settings The action code settings which defines whether
+   *     the link is to be handled by a mobile app and the additional state information to be
+   *     passed in the deep link, etc.
+   * @return An {@code ApiFuture} which will complete successfully with the generated email action
+   *     link. If an error occurs while generating the link, the future throws a
+   *     {@link FirebaseAuthException}.
+   * @throws IllegalArgumentException If the email address is null or empty.
+   */
+  public ApiFuture<String> generatePasswordResetLinkAsync(
+      @NonNull String email, @Nullable ActionCodeSettings settings) {
+    return generateEmailActionLinkOp(EmailLinkType.PASSWORD_RESET, email, settings)
+            .callAsync(firebaseApp);
+  }
+
+  /**
+   * Generates the out-of-band email action link for email verification flows for the specified
+   * email address.
+   *
+   * @param email The email of the user to be verified.
+   * @return An email verification link.
+   * @throws IllegalArgumentException If the email address is null or empty.
+   * @throws FirebaseAuthException If an error occurs while generating the link.
+   */
+  public String generateEmailVerificationLink(@NonNull String email) throws FirebaseAuthException {
+    return generateEmailVerificationLink(email, null);
+  }
+
+  /**
+   * Generates the out-of-band email action link for email verification flows for the specified
+   * email address, using the action code settings provided.
+   *
+   * @param email The email of the user to be verified.
+   * @return An email verification link.
+   * @throws IllegalArgumentException If the email address is null or empty.
+   * @throws FirebaseAuthException If an error occurs while generating the link.
+   */
+  public String generateEmailVerificationLink(
+      @NonNull String email, @Nullable ActionCodeSettings settings) throws FirebaseAuthException {
+    return generateEmailActionLinkOp(EmailLinkType.VERIFY_EMAIL, email, settings).call();
+  }
+
+  /**
+   * Similar to {@link #generateEmailVerificationLink(String)} but performs the
+   * operation asynchronously.
+   *
+   * @param email The email of the user to be verified.
+   * @return An {@code ApiFuture} which will complete successfully with the generated email action
+   *     link. If an error occurs while generating the link, the future throws a
+   *     {@link FirebaseAuthException}.
+   * @throws IllegalArgumentException If the email address is null or empty.
+   */
+  public ApiFuture<String> generateEmailVerificationLinkAsync(@NonNull String email) {
+    return generateEmailVerificationLinkAsync(email, null);
+  }
+
+  /**
+   * Similar to {@link #generateEmailVerificationLink(String, ActionCodeSettings)} but performs the
+   * operation asynchronously.
+   *
+   * @param email The email of the user to be verified.
+   * @param settings The action code settings which defines whether
+   *     the link is to be handled by a mobile app and the additional state information to be
+   *     passed in the deep link, etc.
+   * @return An {@code ApiFuture} which will complete successfully with the generated email action
+   *     link. If an error occurs while generating the link, the future throws a
+   *     {@link FirebaseAuthException}.
+   * @throws IllegalArgumentException If the email address is null or empty.
+   */
+  public ApiFuture<String> generateEmailVerificationLinkAsync(
+      @NonNull String email, @Nullable ActionCodeSettings settings) {
+    return generateEmailActionLinkOp(EmailLinkType.VERIFY_EMAIL, email, settings)
+            .callAsync(firebaseApp);
+  }
+
+  /**
+   * Generates the out-of-band email action link for email link sign-in flows, using the action
+   * code settings provided.
+   *
+   * @param email The email of the user signing in.
+   * @param settings The action code settings which defines whether
+   *     the link is to be handled by a mobile app and the additional state information to be
+   *     passed in the deep link, etc.
+   * @return An email verification link.
+   * @throws IllegalArgumentException If the email address is null or empty.
+   * @throws FirebaseAuthException If an error occurs while generating the link.
+   */
+  public String generateSignInWithEmailLink(
+      @NonNull String email, @NonNull ActionCodeSettings settings) throws FirebaseAuthException {
+    return generateEmailActionLinkOp(EmailLinkType.EMAIL_SIGNIN, email, settings).call();
+  }
+
+  /**
+   * Similar to {@link #generateSignInWithEmailLink(String, ActionCodeSettings)} but performs the
+   * operation asynchronously.
+   *
+   * @param email The email of the user signing in.
+   * @param settings The action code settings which defines whether
+   *     the link is to be handled by a mobile app and the additional state information to be
+   *     passed in the deep link, etc.
+   * @return An {@code ApiFuture} which will complete successfully with the generated email action
+   *     link. If an error occurs while generating the link, the future throws a
+   *     {@link FirebaseAuthException}.
+   * @throws IllegalArgumentException If the email address is null or empty.
+   * @throws NullPointerException If the settings is null.
+   */
+  public ApiFuture<String> generateSignInWithEmailLinkAsync(
+          String email, @NonNull ActionCodeSettings settings) {
+    return generateEmailActionLinkOp(EmailLinkType.EMAIL_SIGNIN, email, settings)
+            .callAsync(firebaseApp);
+  }
+
+  private CallableOperation<String, FirebaseAuthException> generateEmailActionLinkOp(
+          final EmailLinkType type, final String email, final ActionCodeSettings settings) {
+    checkNotDestroyed();
+    checkArgument(!Strings.isNullOrEmpty(email), "email must not be null or empty");
+    if (type == EmailLinkType.EMAIL_SIGNIN) {
+      checkNotNull(settings, "ActionCodeSettings must not be null when generating sign-in links");
+    }
+    return new CallableOperation<String, FirebaseAuthException>() {
+      @Override
+      protected String execute() throws FirebaseAuthException {
+        return userManager.getEmailActionLink(type, email, settings);
+      }
+    };
+  }
+
   @VisibleForTesting
   FirebaseUserManager getUserManager() {
     return this.userManager;
diff --git a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
index 294f10a..7f534fd 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
@@ -85,6 +85,8 @@ class FirebaseUserManager {
       .put("PROJECT_NOT_FOUND", "project-not-found")
       .put("USER_NOT_FOUND", USER_NOT_FOUND_ERROR)
       .put("WEAK_PASSWORD", "invalid-password")
+      .put("UNAUTHORIZED_DOMAIN", "unauthorized-continue-uri")
+      .put("INVALID_DYNAMIC_LINK_DOMAIN", "invalid-dynamic-link-domain")
       .build();
 
   static final int MAX_LIST_USERS_RESULTS = 1000;
@@ -235,6 +237,25 @@ class FirebaseUserManager {
     throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to create session cookie");
   }
 
+  String getEmailActionLink(EmailLinkType type, String email,
+      @Nullable ActionCodeSettings settings) throws FirebaseAuthException {
+    ImmutableMap.Builder<String, Object> payload = ImmutableMap.<String, Object>builder()
+            .put("requestType", type.name())
+            .put("email", email)
+            .put("returnOobLink", true);
+    if (settings != null) {
+      payload.putAll(settings.getProperties());
+    }
+    GenericJson response = post("/accounts:sendOobCode", payload.build(), GenericJson.class);
+    if (response != null) {
+      String link = (String) response.get("oobLink");
+      if (!Strings.isNullOrEmpty(link)) {
+        return link;
+      }
+    }
+    throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to create email action link");
+  }
+
   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");
@@ -325,4 +346,10 @@ class FirebaseUserManager {
       return users.size();
     }
   }
+
+  enum EmailLinkType {
+    VERIFY_EMAIL,
+    EMAIL_SIGNIN,
+    PASSWORD_RESET,
+  }
 }


Test patch:
diff --git a/src/test/java/com/google/firebase/auth/ActionCodeSettingsTest.java b/src/test/java/com/google/firebase/auth/ActionCodeSettingsTest.java
new file mode 100644
index 0000000..501aa53
--- /dev/null
+++ b/src/test/java/com/google/firebase/auth/ActionCodeSettingsTest.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.auth;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+import org.junit.Test;
+
+public class ActionCodeSettingsTest {
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testNoUrl() {
+    ActionCodeSettings.builder().build();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testMalformedUrl() {
+    ActionCodeSettings.builder()
+        .setUrl("not a url")
+        .build();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testEmptyUrl() {
+    ActionCodeSettings.builder()
+        .setUrl("")
+        .build();
+  }
+
+  @Test
+  public void testUrlOnly() {
+    ActionCodeSettings settings = ActionCodeSettings.builder()
+        .setUrl("https://example.com")
+        .build();
+    Map<String, Object> expected = ImmutableMap.<String, Object>of(
+        "continueUrl", "https://example.com", "canHandleCodeInApp", false);
+    assertEquals(expected, settings.getProperties());
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testNoAndroidPackageName() {
+    ActionCodeSettings.builder()
+        .setUrl("https://example.com")
+        .setAndroidMinimumVersion("6.0")
+        .setAndroidInstallApp(true)
+        .build();
+  }
+
+  @Test
+  public void testAllSettings() {
+    ActionCodeSettings settings = ActionCodeSettings.builder()
+        .setUrl("https://example.com")
+        .setHandleCodeInApp(true)
+        .setDynamicLinkDomain("myapp.page.link")
+        .setIosBundleId("com.example.ios")
+        .setAndroidPackageName("com.example.android")
+        .setAndroidMinimumVersion("6.0")
+        .setAndroidInstallApp(true)
+        .build();
+    Map<String, Object> expected = ImmutableMap.<String, Object>builder()
+        .put("continueUrl", "https://example.com")
+        .put("canHandleCodeInApp", true)
+        .put("dynamicLinkDomain", "myapp.page.link")
+        .put("iOSBundleId", "com.example.ios")
+        .put("androidPackageName", "com.example.android")
+        .put("androidMinimumVersion", "6.0")
+        .put("androidInstallApp", true)
+        .build();
+    assertEquals(expected, settings.getProperties());
+  }
+}
diff --git a/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java b/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
index 54c2259..b01eeb9 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
@@ -42,6 +42,7 @@ import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.io.BaseEncoding;
+import com.google.common.util.concurrent.MoreExecutors;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.FirebaseOptions;
 import com.google.firebase.ImplFirebaseTrampolines;
@@ -50,7 +51,9 @@ import com.google.firebase.auth.UserRecord.UpdateRequest;
 import com.google.firebase.auth.hash.Scrypt;
 import com.google.firebase.testing.IntegrationTestUtils;
 import java.io.IOException;
+import java.net.URLDecoder;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Random;
@@ -66,12 +69,17 @@ import org.junit.Test;
 
 public class FirebaseAuthIT {
 
-  private static final String ID_TOOLKIT_URL =
+  private static final String VERIFY_CUSTOM_TOKEN_URL =
       "https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken";
-  private static final String ID_TOOLKIT_URL2 =
+  private static final String VERIFY_PASSWORD_URL =
       "https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword";
+  private static final String RESET_PASSWORD_URL =
+      "https://www.googleapis.com/identitytoolkit/v3/relyingparty/resetPassword";
+  private static final String EMAIL_LINK_SIGN_IN_URL =
+      "https://www.googleapis.com/identitytoolkit/v3/relyingparty/emailLinkSignin";
   private static final JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
   private static final HttpTransport transport = Utils.getDefaultTransport();
+  public static final String ACTION_LINK_CONTINUE_URL = "http://localhost/?a=1&b=2#c=3";
 
   private static FirebaseAuth auth;
 
@@ -129,24 +137,13 @@ public class FirebaseAuthIT {
     }
   }
 
-  private String randomPhoneNumber() {
-    Random random = new Random();
-    StringBuilder builder = new StringBuilder("+1");
-    for (int i = 0; i < 10; i++) {
-      builder.append(random.nextInt(10));
-    }
-    return builder.toString();
-  }
-
   @Test
   public void testCreateUserWithParams() throws Exception {
-    String randomId = UUID.randomUUID().toString().replaceAll("-", "");
-    String userEmail = ("test" + randomId.substring(0, 12) + "@example." + randomId.substring(12)
-        + ".com").toLowerCase();
+    RandomUser randomUser = RandomUser.create();
     String phone = randomPhoneNumber();
     CreateRequest user = new CreateRequest()
-        .setUid(randomId)
-        .setEmail(userEmail)
+        .setUid(randomUser.uid)
+        .setEmail(randomUser.email)
         .setPhoneNumber(phone)
         .setDisplayName("Random User")
         .setPhotoUrl("https://example.com/photo.png")
@@ -155,9 +152,9 @@ public class FirebaseAuthIT {
 
     UserRecord userRecord = auth.createUserAsync(user).get();
     try {
-      assertEquals(randomId, userRecord.getUid());
+      assertEquals(randomUser.uid, userRecord.getUid());
       assertEquals("Random User", userRecord.getDisplayName());
-      assertEquals(userEmail, userRecord.getEmail());
+      assertEquals(randomUser.email, userRecord.getEmail());
       assertEquals(phone, userRecord.getPhoneNumber());
       assertEquals("https://example.com/photo.png", userRecord.getPhotoUrl());
       assertTrue(userRecord.isEmailVerified());
@@ -171,7 +168,7 @@ public class FirebaseAuthIT {
       assertTrue(providers.contains("password"));
       assertTrue(providers.contains("phone"));
 
-      checkRecreate(randomId);
+      checkRecreate(randomUser.uid);
     } finally {
       auth.deleteUserAsync(userRecord.getUid()).get();
     }
@@ -198,13 +195,11 @@ public class FirebaseAuthIT {
     assertTrue(userRecord.getCustomClaims().isEmpty());
 
     // Update user
-    String randomId = UUID.randomUUID().toString().replaceAll("-", "");
-    String userEmail = ("test" + randomId.substring(0, 12) + "@example." + randomId.substring(12)
-        + ".com").toLowerCase();
+    RandomUser randomUser = RandomUser.create();
     String phone = randomPhoneNumber();
     UpdateRequest request = userRecord.updateRequest()
         .setDisplayName("Updated Name")
-        .setEmail(userEmail)
+        .setEmail(randomUser.email)
         .setPhoneNumber(phone)
         .setPhotoUrl("https://example.com/photo.png")
         .setEmailVerified(true)
@@ -212,7 +207,7 @@ public class FirebaseAuthIT {
     userRecord = auth.updateUserAsync(request).get();
     assertEquals(uid, userRecord.getUid());
     assertEquals("Updated Name", userRecord.getDisplayName());
-    assertEquals(userEmail, userRecord.getEmail());
+    assertEquals(randomUser.email, userRecord.getEmail());
     assertEquals(phone, userRecord.getPhoneNumber());
     assertEquals("https://example.com/photo.png", userRecord.getPhotoUrl());
     assertTrue(userRecord.isEmailVerified());
@@ -233,7 +228,7 @@ public class FirebaseAuthIT {
     userRecord = auth.updateUserAsync(request).get();
     assertEquals(uid, userRecord.getUid());
     assertNull(userRecord.getDisplayName());
-    assertEquals(userEmail, userRecord.getEmail());
+    assertEquals(randomUser.email, userRecord.getEmail());
     assertNull(userRecord.getPhoneNumber());
     assertNull(userRecord.getPhotoUrl());
     assertTrue(userRecord.isEmailVerified());
@@ -312,7 +307,7 @@ public class FirebaseAuthIT {
           }
           semaphore.release();
         }
-      });
+      }, MoreExecutors.directExecutor());
       semaphore.acquire();
       assertEquals(uids.size(), collected.get());
       assertNull(error.get());
@@ -378,7 +373,7 @@ public class FirebaseAuthIT {
       token = credentials.refreshAccessToken();
     }
     FirebaseOptions options = new FirebaseOptions.Builder()
-        .setCredentials(GoogleCredentials.of(token))
+        .setCredentials(GoogleCredentials.create(token))
         .setServiceAccountId(((ServiceAccountSigner) credentials).getAccount())
         .setProjectId(IntegrationTestUtils.getProjectId())
         .build();
@@ -470,12 +465,10 @@ public class FirebaseAuthIT {
 
   @Test
   public void testImportUsers() throws Exception {
-    final String randomId = UUID.randomUUID().toString().replaceAll("-", "");
-    final String userEmail = ("test" + randomId.substring(0, 12) + "@example."
-        + randomId.substring(12) + ".com").toLowerCase();
+    RandomUser randomUser = RandomUser.create();
     ImportUserRecord user = ImportUserRecord.builder()
-        .setUid(randomId)
-        .setEmail(userEmail)
+        .setUid(randomUser.uid)
+        .setEmail(randomUser.email)
         .build();
 
     UserImportResult result = auth.importUsersAsync(ImmutableList.of(user)).get();
@@ -483,23 +476,21 @@ public class FirebaseAuthIT {
     assertEquals(0, result.getFailureCount());
 
     try {
-      UserRecord savedUser = auth.getUserAsync(randomId).get();
-      assertEquals(userEmail, savedUser.getEmail());
+      UserRecord savedUser = auth.getUserAsync(randomUser.uid).get();
+      assertEquals(randomUser.email, savedUser.getEmail());
     } finally {
-      auth.deleteUserAsync(randomId).get();
+      auth.deleteUserAsync(randomUser.uid).get();
     }
   }
 
   @Test
   public void testImportUsersWithPassword() throws Exception {
-    final String randomId = UUID.randomUUID().toString().replaceAll("-", "");
-    final String userEmail = ("test" + randomId.substring(0, 12) + "@example."
-        + randomId.substring(12) + ".com").toLowerCase();
+    RandomUser randomUser = RandomUser.create();
     final byte[] passwordHash = BaseEncoding.base64().decode(
         "V358E8LdWJXAO7muq0CufVpEOXaj8aFiC7T/rcaGieN04q/ZPJ08WhJEHGjj9lz/2TT+/86N5VjVoc5DdBhBiw==");
     ImportUserRecord user = ImportUserRecord.builder()
-        .setUid(randomId)
-        .setEmail(userEmail)
+        .setUid(randomUser.uid)
+        .setEmail(randomUser.email)
         .setPasswordHash(passwordHash)
         .setPasswordSalt("NaCl".getBytes())
         .build();
@@ -519,17 +510,112 @@ public class FirebaseAuthIT {
     assertEquals(0, result.getFailureCount());
 
     try {
-      UserRecord savedUser = auth.getUserAsync(randomId).get();
-      assertEquals(userEmail, savedUser.getEmail());
-      String idToken = signInWithPassword(userEmail, "password");
+      UserRecord savedUser = auth.getUserAsync(randomUser.uid).get();
+      assertEquals(randomUser.email, savedUser.getEmail());
+      String idToken = signInWithPassword(randomUser.email, "password");
       assertFalse(Strings.isNullOrEmpty(idToken));
     } finally {
-      auth.deleteUserAsync(randomId).get();
+      auth.deleteUserAsync(randomUser.uid).get();
+    }
+  }
+
+  @Test
+  public void testGeneratePasswordResetLink() throws Exception {
+    RandomUser user = RandomUser.create();
+    auth.createUser(new CreateRequest()
+        .setUid(user.uid)
+        .setEmail(user.email)
+        .setEmailVerified(false)
+        .setPassword("password"));
+    try {
+      String link = auth.generatePasswordResetLink(user.email, ActionCodeSettings.builder()
+          .setUrl(ACTION_LINK_CONTINUE_URL)
+          .setHandleCodeInApp(false)
+          .build());
+      Map<String, String> linkParams = parseLinkParameters(link);
+      assertEquals(ACTION_LINK_CONTINUE_URL, linkParams.get("continueUrl"));
+      String email = resetPassword(user.email, "password", "newpassword",
+          linkParams.get("oobCode"));
+      assertEquals(user.email, email);
+      // Password reset also verifies the user's email
+      assertTrue(auth.getUser(user.uid).isEmailVerified());
+    } finally {
+      auth.deleteUser(user.uid);
+    }
+  }
+
+  @Test
+  public void testGenerateEmailVerificationResetLink() throws Exception {
+    RandomUser user = RandomUser.create();
+    auth.createUser(new CreateRequest()
+        .setUid(user.uid)
+        .setEmail(user.email)
+        .setEmailVerified(false)
+        .setPassword("password"));
+    try {
+      String link = auth.generateEmailVerificationLink(user.email, ActionCodeSettings.builder()
+          .setUrl(ACTION_LINK_CONTINUE_URL)
+          .setHandleCodeInApp(false)
+          .build());
+      Map<String, String> linkParams = parseLinkParameters(link);
+      assertEquals(ACTION_LINK_CONTINUE_URL, linkParams.get("continueUrl"));
+      // There doesn't seem to be a public API for verifying an email, so we cannot do a more
+      // thorough test here.
+      assertEquals("verifyEmail", linkParams.get("mode"));
+    } finally {
+      auth.deleteUser(user.uid);
+    }
+  }
+
+  @Test
+  public void testGenerateSignInWithEmailLink() throws Exception {
+    RandomUser user = RandomUser.create();
+    auth.createUser(new CreateRequest()
+        .setUid(user.uid)
+        .setEmail(user.email)
+        .setEmailVerified(false)
+        .setPassword("password"));
+    try {
+      String link = auth.generateSignInWithEmailLink(user.email, ActionCodeSettings.builder()
+          .setUrl(ACTION_LINK_CONTINUE_URL)
+          .setHandleCodeInApp(false)
+          .build());
+      Map<String, String> linkParams = parseLinkParameters(link);
+      assertEquals(ACTION_LINK_CONTINUE_URL, linkParams.get("continueUrl"));
+      String idToken = signInWithEmailLink(user.email, linkParams.get("oobCode"));
+      assertFalse(Strings.isNullOrEmpty(idToken));
+      assertTrue(auth.getUser(user.uid).isEmailVerified());
+    } finally {
+      auth.deleteUser(user.uid);
+    }
+  }
+
+  private Map<String, String> parseLinkParameters(String link) throws Exception {
+    Map<String, String> result = new HashMap<>();
+    int queryBegin = link.indexOf('?');
+    if (queryBegin != -1) {
+      String[] segments = link.substring(queryBegin + 1).split("&");
+      for (String segment : segments) {
+        int equalSign = segment.indexOf('=');
+        String key = segment.substring(0, equalSign);
+        String value = segment.substring(equalSign + 1);
+        result.put(key, URLDecoder.decode(value, "UTF-8"));
+      }
+    }
+    return result;
+  }
+
+  private String randomPhoneNumber() {
+    Random random = new Random();
+    StringBuilder builder = new StringBuilder("+1");
+    for (int i = 0; i < 10; i++) {
+      builder.append(random.nextInt(10));
     }
+    return builder.toString();
   }
 
   private String signInWithCustomToken(String customToken) throws IOException {
-    GenericUrl url = new GenericUrl(ID_TOOLKIT_URL + "?key="
+    GenericUrl url = new GenericUrl(VERIFY_CUSTOM_TOKEN_URL + "?key="
         + IntegrationTestUtils.getApiKey());
     Map<String, Object> content = ImmutableMap.<String, Object>of(
         "token", customToken, "returnSecureToken", true);
@@ -546,7 +632,7 @@ public class FirebaseAuthIT {
   }
 
   private String signInWithPassword(String email, String password) throws IOException {
-    GenericUrl url = new GenericUrl(ID_TOOLKIT_URL2 + "?key="
+    GenericUrl url = new GenericUrl(VERIFY_PASSWORD_URL + "?key="
         + IntegrationTestUtils.getApiKey());
     Map<String, Object> content = ImmutableMap.<String, Object>of(
         "email", email, "password", password);
@@ -562,6 +648,42 @@ public class FirebaseAuthIT {
     }
   }
 
+  private String resetPassword(
+      String email, String oldPassword, String newPassword, String oobCode) throws IOException {
+    GenericUrl url = new GenericUrl(RESET_PASSWORD_URL + "?key="
+        + IntegrationTestUtils.getApiKey());
+    Map<String, Object> content = ImmutableMap.<String, Object>of(
+        "email", email, "oldPassword", oldPassword, "newPassword", newPassword, "oobCode", oobCode);
+    HttpRequest request = transport.createRequestFactory().buildPostRequest(url,
+        new JsonHttpContent(jsonFactory, content));
+    request.setParser(new JsonObjectParser(jsonFactory));
+    HttpResponse response = request.execute();
+    try {
+      GenericJson json = response.parseAs(GenericJson.class);
+      return json.get("email").toString();
+    } finally {
+      response.disconnect();
+    }
+  }
+
+  private String signInWithEmailLink(
+      String email, String oobCode) throws IOException {
+    GenericUrl url = new GenericUrl(EMAIL_LINK_SIGN_IN_URL + "?key="
+        + IntegrationTestUtils.getApiKey());
+    Map<String, Object> content = ImmutableMap.<String, Object>of(
+        "email", email, "oobCode", oobCode);
+    HttpRequest request = transport.createRequestFactory().buildPostRequest(url,
+        new JsonHttpContent(jsonFactory, content));
+    request.setParser(new JsonObjectParser(jsonFactory));
+    HttpResponse response = request.execute();
+    try {
+      GenericJson json = response.parseAs(GenericJson.class);
+      return json.get("idToken").toString();
+    } finally {
+      response.disconnect();
+    }
+  }
+
   private void checkRecreate(String uid) throws Exception {
     try {
       auth.createUserAsync(new CreateRequest().setUid(uid)).get();
@@ -571,4 +693,21 @@ public class FirebaseAuthIT {
       assertEquals("uid-already-exists", ((FirebaseAuthException) e.getCause()).getErrorCode());
     }
   }
+
+  private static class RandomUser {
+    private final String uid;
+    private final String email;
+
+    private RandomUser(String uid, String email) {
+      this.uid = uid;
+      this.email = email;
+    }
+
+    static RandomUser create() {
+      final String uid = UUID.randomUUID().toString().replaceAll("-", "");
+      final String email = ("test" + uid.substring(0, 12) + "@example."
+          + uid.substring(12) + ".com").toLowerCase();
+      return new RandomUser(uid, email);
+    }
+  }
 }
diff --git a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
index fe6f000..72e81fd 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
@@ -39,6 +39,7 @@ import com.google.common.collect.Iterables;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.FirebaseOptions;
 import com.google.firebase.TestOnlyImplFirebaseTrampolines;
+import com.google.firebase.auth.FirebaseUserManager.EmailLinkType;
 import com.google.firebase.auth.UserRecord.CreateRequest;
 import com.google.firebase.auth.UserRecord.UpdateRequest;
 import com.google.firebase.internal.SdkUtils;
@@ -62,6 +63,17 @@ public class FirebaseUserManagerTest {
 
   private static final String TEST_TOKEN = "token";
   private static final GoogleCredentials credentials = new MockGoogleCredentials(TEST_TOKEN);
+  private static final ActionCodeSettings ACTION_CODE_SETTINGS = ActionCodeSettings.builder()
+          .setUrl("https://example.dynamic.link")
+          .setHandleCodeInApp(true)
+          .setDynamicLinkDomain("custom.page.link")
+          .setIosBundleId("com.example.ios")
+          .setAndroidPackageName("com.example.android")
+          .setAndroidInstallApp(true)
+          .setAndroidMinimumVersion("6")
+          .build();
+  private static final Map<String, Object> ACTION_CODE_SETTINGS_MAP =
+          ACTION_CODE_SETTINGS.getProperties();
 
   @After
   public void tearDown() {
@@ -988,6 +1000,213 @@ public class FirebaseUserManagerTest {
     }
   }
 
+  @Test
+  public void testGeneratePasswordResetLinkNoEmail() throws Exception {
+    initializeAppForUserManagement();
+    try {
+      FirebaseAuth.getInstance().generatePasswordResetLinkAsync(null).get();
+      fail("No error thrown for null email");
+    } catch (IllegalArgumentException expected) {
+    }
+
+    try {
+      FirebaseAuth.getInstance().generatePasswordResetLinkAsync("").get();
+      fail("No error thrown for empty email");
+    } catch (IllegalArgumentException expected) {
+    }
+  }
+
+  @Test
+  public void testGeneratePasswordResetLinkWithSettings() throws Exception {
+    TestResponseInterceptor interceptor = initializeAppForUserManagement(
+            TestUtils.loadResource("generateEmailLink.json"));
+    String link = FirebaseAuth.getInstance()
+            .generatePasswordResetLinkAsync("test@example.com", ACTION_CODE_SETTINGS).get();
+    assertEquals("https://mock-oob-link.for.auth.tests", link);
+    checkRequestHeaders(interceptor);
+
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    interceptor.getResponse().getRequest().getContent().writeTo(out);
+    JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
+    GenericJson parsed = jsonFactory.fromString(new String(out.toByteArray()), GenericJson.class);
+    assertEquals(3 + ACTION_CODE_SETTINGS_MAP.size(), parsed.size());
+    assertEquals("test@example.com", parsed.get("email"));
+    assertEquals("PASSWORD_RESET", parsed.get("requestType"));
+    assertTrue((Boolean) parsed.get("returnOobLink"));
+    for (Map.Entry<String, Object> entry : ACTION_CODE_SETTINGS_MAP.entrySet()) {
+      assertEquals(entry.getValue(), parsed.get(entry.getKey()));
+    }
+  }
+
+  @Test
+  public void testGeneratePasswordResetLink() throws Exception {
+    TestResponseInterceptor interceptor = initializeAppForUserManagement(
+            TestUtils.loadResource("generateEmailLink.json"));
+    String link = FirebaseAuth.getInstance()
+            .generatePasswordResetLinkAsync("test@example.com").get();
+    assertEquals("https://mock-oob-link.for.auth.tests", link);
+    checkRequestHeaders(interceptor);
+
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    interceptor.getResponse().getRequest().getContent().writeTo(out);
+    JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
+    GenericJson parsed = jsonFactory.fromString(new String(out.toByteArray()), GenericJson.class);
+    assertEquals(3, parsed.size());
+    assertEquals("test@example.com", parsed.get("email"));
+    assertEquals("PASSWORD_RESET", parsed.get("requestType"));
+    assertTrue((Boolean) parsed.get("returnOobLink"));
+  }
+
+  @Test
+  public void testGenerateEmailVerificationLinkNoEmail() throws Exception {
+    initializeAppForUserManagement();
+    try {
+      FirebaseAuth.getInstance().generateEmailVerificationLinkAsync(null).get();
+      fail("No error thrown for null email");
+    } catch (IllegalArgumentException expected) {
+    }
+
+    try {
+      FirebaseAuth.getInstance().generateEmailVerificationLinkAsync("").get();
+      fail("No error thrown for empty email");
+    } catch (IllegalArgumentException expected) {
+    }
+  }
+
+  @Test
+  public void testGenerateEmailVerificationLinkWithSettings() throws Exception {
+    TestResponseInterceptor interceptor = initializeAppForUserManagement(
+        TestUtils.loadResource("generateEmailLink.json"));
+    String link = FirebaseAuth.getInstance()
+        .generateEmailVerificationLinkAsync("test@example.com", ACTION_CODE_SETTINGS).get();
+    assertEquals("https://mock-oob-link.for.auth.tests", link);
+    checkRequestHeaders(interceptor);
+
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    interceptor.getResponse().getRequest().getContent().writeTo(out);
+    JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
+    GenericJson parsed = jsonFactory.fromString(new String(out.toByteArray()), GenericJson.class);
+    assertEquals(3 + ACTION_CODE_SETTINGS_MAP.size(), parsed.size());
+    assertEquals("test@example.com", parsed.get("email"));
+    assertEquals("VERIFY_EMAIL", parsed.get("requestType"));
+    assertTrue((Boolean) parsed.get("returnOobLink"));
+    for (Map.Entry<String, Object> entry : ACTION_CODE_SETTINGS_MAP.entrySet()) {
+      assertEquals(entry.getValue(), parsed.get(entry.getKey()));
+    }
+  }
+
+  @Test
+  public void testGenerateEmailVerificationLink() throws Exception {
+    TestResponseInterceptor interceptor = initializeAppForUserManagement(
+        TestUtils.loadResource("generateEmailLink.json"));
+    String link = FirebaseAuth.getInstance()
+        .generateEmailVerificationLinkAsync("test@example.com").get();
+    assertEquals("https://mock-oob-link.for.auth.tests", link);
+    checkRequestHeaders(interceptor);
+
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    interceptor.getResponse().getRequest().getContent().writeTo(out);
+    JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
+    GenericJson parsed = jsonFactory.fromString(new String(out.toByteArray()), GenericJson.class);
+    assertEquals(3, parsed.size());
+    assertEquals("test@example.com", parsed.get("email"));
+    assertEquals("VERIFY_EMAIL", parsed.get("requestType"));
+    assertTrue((Boolean) parsed.get("returnOobLink"));
+  }
+
+  @Test
+  public void testGenerateESignInWithEmailLinkNoEmail() throws Exception {
+    initializeAppForUserManagement();
+    try {
+      FirebaseAuth.getInstance().generateSignInWithEmailLinkAsync(
+          null, ACTION_CODE_SETTINGS).get();
+      fail("No error thrown for null email");
+    } catch (IllegalArgumentException expected) {
+    }
+
+    try {
+      FirebaseAuth.getInstance().generateSignInWithEmailLinkAsync(
+          "", ACTION_CODE_SETTINGS).get();
+      fail("No error thrown for empty email");
+    } catch (IllegalArgumentException expected) {
+    }
+  }
+
+  @Test
+  public void testGenerateESignInWithEmailLinkNullSettings() throws Exception {
+    initializeAppForUserManagement();
+    try {
+      FirebaseAuth.getInstance().generateSignInWithEmailLinkAsync(
+          "test@example.com", null).get();
+      fail("No error thrown for null email");
+    } catch (NullPointerException expected) {
+    }
+  }
+
+  @Test
+  public void testGenerateSignInWithEmailLinkWithSettings() throws Exception {
+    TestResponseInterceptor interceptor = initializeAppForUserManagement(
+        TestUtils.loadResource("generateEmailLink.json"));
+    String link = FirebaseAuth.getInstance()
+        .generateSignInWithEmailLinkAsync("test@example.com", ACTION_CODE_SETTINGS).get();
+    assertEquals("https://mock-oob-link.for.auth.tests", link);
+    checkRequestHeaders(interceptor);
+
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    interceptor.getResponse().getRequest().getContent().writeTo(out);
+    JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
+    GenericJson parsed = jsonFactory.fromString(new String(out.toByteArray()), GenericJson.class);
+    assertEquals(3 + ACTION_CODE_SETTINGS_MAP.size(), parsed.size());
+    assertEquals("test@example.com", parsed.get("email"));
+    assertEquals("EMAIL_SIGNIN", parsed.get("requestType"));
+    assertTrue((Boolean) parsed.get("returnOobLink"));
+    for (Map.Entry<String, Object> entry : ACTION_CODE_SETTINGS_MAP.entrySet()) {
+      assertEquals(entry.getValue(), parsed.get(entry.getKey()));
+    }
+  }
+
+  @Test
+  public void testHttpErrorWithCode() {
+    FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+        .setCredentials(credentials)
+        .setHttpTransport(new MultiRequestMockHttpTransport(ImmutableList.of(
+            new MockLowLevelHttpResponse()
+                .setContent("{\"error\": {\"message\": \"UNAUTHORIZED_DOMAIN\"}}")
+                .setStatusCode(500))))
+        .setProjectId("test-project-id")
+        .build());
+    FirebaseAuth auth = FirebaseAuth.getInstance();
+    FirebaseUserManager userManager = auth.getUserManager();
+    try {
+      userManager.getEmailActionLink(EmailLinkType.PASSWORD_RESET, "test@example.com", null);
+      fail("No exception thrown for HTTP error");
+    } catch (FirebaseAuthException e) {
+      assertEquals("unauthorized-continue-uri", e.getErrorCode());
+      assertTrue(e.getCause() instanceof HttpResponseException);
+    }
+  }
+
+  @Test
+  public void testUnexpectedHttpError() {
+    FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+        .setCredentials(credentials)
+        .setHttpTransport(new MultiRequestMockHttpTransport(ImmutableList.of(
+            new MockLowLevelHttpResponse()
+                .setContent("{}")
+                .setStatusCode(500))))
+        .setProjectId("test-project-id")
+        .build());
+    FirebaseAuth auth = FirebaseAuth.getInstance();
+    FirebaseUserManager userManager = auth.getUserManager();
+    try {
+      userManager.getEmailActionLink(EmailLinkType.PASSWORD_RESET, "test@example.com", null);
+      fail("No exception thrown for HTTP error");
+    } catch (FirebaseAuthException e) {
+      assertEquals("internal-error", e.getErrorCode());
+      assertTrue(e.getCause() instanceof HttpResponseException);
+    }
+  }
+
   private static TestResponseInterceptor initializeAppForUserManagement(String ...responses) {
     List<MockLowLevelHttpResponse> mocks = new ArrayList<>();
     for (String response : responses) {
diff --git a/src/test/java/com/google/firebase/testing/IntegrationTestUtils.java b/src/test/java/com/google/firebase/testing/IntegrationTestUtils.java
index 7d2b9d4..bbfc24e 100644
--- a/src/test/java/com/google/firebase/testing/IntegrationTestUtils.java
+++ b/src/test/java/com/google/firebase/testing/IntegrationTestUtils.java
@@ -113,6 +113,7 @@ public class IntegrationTestUtils {
               .setCredentials(TestUtils.getCertCredential(getServiceAccountCertificate()))
               .setFirestoreOptions(FirestoreOptions.newBuilder()
                   .setTimestampsInSnapshotsEnabled(true)
+                  .setCredentials(TestUtils.getCertCredential(getServiceAccountCertificate()))
                   .build())
               .build();
       masterApp = FirebaseApp.initializeApp(options);
diff --git a/src/test/resources/generateEmailLink.json b/src/test/resources/generateEmailLink.json
new file mode 100644
index 0000000..93f1708
--- /dev/null
+++ b/src/test/resources/generateEmailLink.json
@@ -0,0 +1,4 @@
+{
+  "oobLink": "https://mock-oob-link.for.auth.tests",
+  "email": "test@example.com"
+}