From e52381fdc2e625c2ea7844e9cc435f6426c6f062 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Tue, 10 Mar 2026 15:01:02 +0530 Subject: [PATCH 1/4] feat: add verify and change email link API --- .../firebase/auth/AbstractFirebaseAuth.java | 23 +++++++++++++++++++ .../firebase/auth/FirebaseUserManager.java | 17 +++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java index 44fe9b7d2..c69af111d 100644 --- a/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java +++ b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java @@ -1069,6 +1069,29 @@ protected DeleteUsersResult execute() throws FirebaseAuthException { }; } + public String generateVerifyAndChangeEmailLink(@NonNull String email, @NonNull String newEmail) + throws FirebaseAuthException { + return generateVerifyAndChangeEmailLink(email, newEmail, null); + } + + public String generateVerifyAndChangeEmailLink( + @NonNull String email, @NonNull String newEmail, @Nullable ActionCodeSettings settings) + throws FirebaseAuthException { + return getUserManager().getEmailActionLink( + EmailLinkType.VERIFY_AND_CHANGE_EMAIL, email, newEmail, settings); + } + + public ApiFuture generateVerifyAndChangeEmailLinkAsync( + @NonNull String email, @NonNull String newEmail) { + return generateVerifyAndChangeEmailLinkAsync(email, newEmail, null); + } + + public ApiFuture generateVerifyAndChangeEmailLinkAsync( + @NonNull String email, @NonNull String newEmail, @Nullable ActionCodeSettings settings) { + return CallableOperation.getInstance().callAsync( + () -> generateVerifyAndChangeEmailLink(email, newEmail, settings)); + } + /** * Generates the out-of-band email action link for password reset flows for the specified email * address. diff --git a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java index 0647084c8..7c2587e7e 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java +++ b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java @@ -219,10 +219,20 @@ String createSessionCookie(String idToken, String getEmailActionLink(EmailLinkType type, String email, @Nullable ActionCodeSettings settings) throws FirebaseAuthException { + return getEmailActionLink(type, email, null, settings); + } + + String getEmailActionLink(EmailLinkType type, String email, @Nullable String newEmail, + @Nullable ActionCodeSettings settings) throws FirebaseAuthException { ImmutableMap.Builder payload = ImmutableMap.builder() - .put("requestType", type.name()) - .put("email", email) - .put("returnOobLink", true); + .put("requestType", type.name()) + .put("email", email) + .put("returnOobLink", true); + + if (newEmail != null) { + payload.put("newEmail", newEmail); + } + if (settings != null) { payload.putAll(settings.getProperties()); } @@ -389,6 +399,7 @@ enum EmailLinkType { VERIFY_EMAIL, EMAIL_SIGNIN, PASSWORD_RESET, + VERIFY_AND_CHANGE_EMAIL, } static FirebaseUserManager createUserManager(FirebaseApp app, String tenantId) { From 2aa3883c3a70dee04ae3e0118cf7aafe2bae3032 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Wed, 11 Mar 2026 00:02:54 +0530 Subject: [PATCH 2/4] feat(auth): add generateVerifyAndChangeEmailLink API --- .../firebase/auth/AbstractFirebaseAuth.java | 8 ++++++-- .../auth/FirebaseUserManagerTest.java | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java index c69af111d..365b629b2 100644 --- a/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java +++ b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java @@ -1088,8 +1088,12 @@ public ApiFuture generateVerifyAndChangeEmailLinkAsync( public ApiFuture generateVerifyAndChangeEmailLinkAsync( @NonNull String email, @NonNull String newEmail, @Nullable ActionCodeSettings settings) { - return CallableOperation.getInstance().callAsync( - () -> generateVerifyAndChangeEmailLink(email, newEmail, settings)); + return new CallableOperation() { + @Override + protected String execute() throws FirebaseAuthException { + return generateVerifyAndChangeEmailLink(email, newEmail, settings); + } + }.callAsync(firebaseApp); } /** diff --git a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java index 057754087..63596d8fe 100644 --- a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java +++ b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java @@ -47,6 +47,7 @@ import com.google.firebase.auth.multitenancy.TenantAwareFirebaseAuth; import com.google.firebase.auth.multitenancy.TenantManager; import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.CallableOperation; import com.google.firebase.internal.FirebaseProcessEnvironment; import com.google.firebase.internal.SdkUtils; import com.google.firebase.testing.MultiRequestMockHttpTransport; @@ -1510,6 +1511,23 @@ public void testGenerateEmailVerificationLink() throws Exception { assertEquals("VERIFY_EMAIL", parsed.get("requestType")); assertTrue((Boolean) parsed.get("returnOobLink")); } + + @Test + public void testGenerateVerifyAndChangeEmailLink() throws Exception { + TestResponseInterceptor interceptor = initializeAppForUserManagement( + TestUtils.loadResource("generateEmailLink.json")); + String link = FirebaseAuth.getInstance() + .generateVerifyAndChangeEmailLinkAsync("test@example.com", "new@example.com").get(); + assertEquals("https://mock-oob-link.for.auth.tests", link); + checkRequestHeaders(interceptor); + + GenericJson parsed = parseRequestContent(interceptor); + assertEquals(4, parsed.size()); + assertEquals("test@example.com", parsed.get("email")); + assertEquals("new@example.com", parsed.get("newEmail")); + assertEquals("VERIFY_AND_CHANGE_EMAIL", parsed.get("requestType")); + assertTrue((Boolean) parsed.get("returnOobLink")); + } @Test public void testGenerateESignInWithEmailLinkNoEmail() throws Exception { @@ -1529,6 +1547,7 @@ public void testGenerateESignInWithEmailLinkNoEmail() throws Exception { } } + @Test public void testGenerateESignInWithEmailLinkNullSettings() throws Exception { initializeAppForUserManagement(); @@ -2864,6 +2883,7 @@ public void testTenantAwareDeleteSamlProviderConfig() throws Exception { checkUrl(interceptor, "DELETE", expectedUrl); } + @Test public void testCreateOidcProviderFromEmulatorAuth() throws Exception { FirebaseProcessEnvironment.setenv("FIREBASE_AUTH_EMULATOR_HOST", AUTH_EMULATOR); From f52801fbed5a2842bfdca3c9ef553575bd4ce4f7 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Wed, 11 Mar 2026 02:14:17 +0530 Subject: [PATCH 3/4] fix: expose API methods, add settings test, and resolve PR feedback --- .../firebase/auth/AbstractFirebaseAuth.java | 107 +++++++++++++----- .../auth/FirebaseUserManagerTest.java | 31 ++++- 2 files changed, 109 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java index 365b629b2..8a8335152 100644 --- a/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java +++ b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java @@ -1069,33 +1069,6 @@ protected DeleteUsersResult execute() throws FirebaseAuthException { }; } - public String generateVerifyAndChangeEmailLink(@NonNull String email, @NonNull String newEmail) - throws FirebaseAuthException { - return generateVerifyAndChangeEmailLink(email, newEmail, null); - } - - public String generateVerifyAndChangeEmailLink( - @NonNull String email, @NonNull String newEmail, @Nullable ActionCodeSettings settings) - throws FirebaseAuthException { - return getUserManager().getEmailActionLink( - EmailLinkType.VERIFY_AND_CHANGE_EMAIL, email, newEmail, settings); - } - - public ApiFuture generateVerifyAndChangeEmailLinkAsync( - @NonNull String email, @NonNull String newEmail) { - return generateVerifyAndChangeEmailLinkAsync(email, newEmail, null); - } - - public ApiFuture generateVerifyAndChangeEmailLinkAsync( - @NonNull String email, @NonNull String newEmail, @Nullable ActionCodeSettings settings) { - return new CallableOperation() { - @Override - protected String execute() throws FirebaseAuthException { - return generateVerifyAndChangeEmailLink(email, newEmail, settings); - } - }.callAsync(firebaseApp); - } - /** * Generates the out-of-band email action link for password reset flows for the specified email * address. @@ -1216,6 +1189,75 @@ public ApiFuture generateEmailVerificationLinkAsync( .callAsync(firebaseApp); } + /** + * Generates the out-of-band email action link for verify and change email flows for the specified + * user. + * + * @param email The email address of the user to be verified. + * @param newEmail The email address to update the user's account to. + * @return A verify and change email link. + * @throws IllegalArgumentException If either email address is null or empty. + * @throws FirebaseAuthException If an error occurs while generating the link. + */ + public String generateVerifyAndChangeEmailLink(@NonNull String email, @NonNull String newEmail) + throws FirebaseAuthException { + return generateVerifyAndChangeEmailLink(email, newEmail, null); + } + + /** + * Generates the out-of-band email action link for verify and change email flows for the specified + * user, using the action code settings provided. + * + * @param email The email address of the user to be verified. + * @param newEmail The email address to update the user's account to. + * @param settings The action code settings object 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. + * @return A verify and change email link. + * @throws IllegalArgumentException If either email address is null or empty. + * @throws FirebaseAuthException If an error occurs while generating the link. + */ + public String generateVerifyAndChangeEmailLink( + @NonNull String email, @NonNull String newEmail, @Nullable ActionCodeSettings settings) + throws FirebaseAuthException { + return generateEmailActionLinkOp(EmailLinkType.VERIFY_AND_CHANGE_EMAIL, email, newEmail, + settings).call(); + } + + /** + * Similar to {@link #generateVerifyAndChangeEmailLink(String, String)} but performs the operation + * asynchronously. + * + * @param email The email address of the user to be verified. + * @param newEmail The email address to update the user's account to. + * @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 either email address is null or empty. + */ + public ApiFuture generateVerifyAndChangeEmailLinkAsync( + @NonNull String email, @NonNull String newEmail) { + return generateVerifyAndChangeEmailLinkAsync(email, newEmail, null); + } + + /** + * Similar to {@link #generateVerifyAndChangeEmailLink(String, String, ActionCodeSettings)} but + * performs the operation asynchronously. + * + * @param email The email address of the user to be verified. + * @param newEmail The email address to update the user's account to. + * @param settings The action code settings object 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. + * @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 either email address is null or empty. + */ + public ApiFuture generateVerifyAndChangeEmailLinkAsync( + @NonNull String email, @NonNull String newEmail, @Nullable ActionCodeSettings settings) { + return generateEmailActionLinkOp(EmailLinkType.VERIFY_AND_CHANGE_EMAIL, email, newEmail, + settings).callAsync(firebaseApp); + } + /** * Generates the out-of-band email action link for email link sign-in flows, using the action code * settings provided. @@ -1253,15 +1295,24 @@ public ApiFuture generateSignInWithEmailLinkAsync( private CallableOperation generateEmailActionLinkOp( final EmailLinkType type, final String email, final ActionCodeSettings settings) { + return generateEmailActionLinkOp(type, email, null, settings); + } + + private CallableOperation generateEmailActionLinkOp( + final EmailLinkType type, final String email, @Nullable final String newEmail, + final ActionCodeSettings settings) { 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"); } + if (type == EmailLinkType.VERIFY_AND_CHANGE_EMAIL) { + checkArgument(!Strings.isNullOrEmpty(newEmail), "newEmail must not be null or empty"); + } final FirebaseUserManager userManager = getUserManager(); return new CallableOperation() { @Override protected String execute() throws FirebaseAuthException { - return userManager.getEmailActionLink(type, email, settings); + return userManager.getEmailActionLink(type, email, newEmail, settings); } }; } diff --git a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java index 63596d8fe..489414591 100644 --- a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java +++ b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java @@ -1511,7 +1511,7 @@ public void testGenerateEmailVerificationLink() throws Exception { assertEquals("VERIFY_EMAIL", parsed.get("requestType")); assertTrue((Boolean) parsed.get("returnOobLink")); } - + @Test public void testGenerateVerifyAndChangeEmailLink() throws Exception { TestResponseInterceptor interceptor = initializeAppForUserManagement( @@ -1529,6 +1529,35 @@ public void testGenerateVerifyAndChangeEmailLink() throws Exception { assertTrue((Boolean) parsed.get("returnOobLink")); } + @Test + public void testGenerateVerifyAndChangeEmailLinkWithSettings() throws Exception { + TestResponseInterceptor interceptor = initializeAppForUserManagement( + TestUtils.loadResource("generateEmailLink.json")); + + // Create custom settings with a continue URL + ActionCodeSettings settings = ActionCodeSettings.builder() + .setUrl("https://example.com/continue") + .setHandleCodeInApp(true) + .build(); + + String link = FirebaseAuth.getInstance() + .generateVerifyAndChangeEmailLinkAsync("test@example.com", + "new@example.com", settings).get(); + + assertEquals("https://mock-oob-link.for.auth.tests", link); + checkRequestHeaders(interceptor); + + GenericJson parsed = parseRequestContent(interceptor); + // We expect 6 fields now because of the newEmail and ActionCodeSettings properties. + assertEquals(6, parsed.size()); + assertEquals("test@example.com", parsed.get("email")); + assertEquals("new@example.com", parsed.get("newEmail")); + assertEquals("VERIFY_AND_CHANGE_EMAIL", parsed.get("requestType")); + assertTrue((Boolean) parsed.get("returnOobLink")); + assertEquals("https://example.com/continue", parsed.get("continueUrl")); + assertTrue((Boolean) parsed.get("canHandleCodeInApp")); + } + @Test public void testGenerateESignInWithEmailLinkNoEmail() throws Exception { initializeAppForUserManagement(); From 23004e55a91db661ed673d02e965a2b2f58075f9 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Wed, 11 Mar 2026 02:42:58 +0530 Subject: [PATCH 4/4] refactor: apply CallableOperation pattern per review --- .../firebase/auth/AbstractFirebaseAuth.java | 64 ++++++++++--------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java index 8a8335152..c6d9f0a2a 100644 --- a/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java +++ b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java @@ -1204,35 +1204,32 @@ public String generateVerifyAndChangeEmailLink(@NonNull String email, @NonNull S return generateVerifyAndChangeEmailLink(email, newEmail, null); } + + /** - * Generates the out-of-band email action link for verify and change email flows for the specified - * user, using the action code settings provided. + * Generates the out-of-band email action link for the verify and change email flow. * - * @param email The email address of the user to be verified. - * @param newEmail The email address to update the user's account to. - * @param settings The action code settings object 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. + * @param email The user's current email. + * @param newEmail The user's new email. + * @param settings The action code settings. * @return A verify and change email link. - * @throws IllegalArgumentException If either email address is null or empty. + * @throws IllegalArgumentException If email or newEmail are null or empty. * @throws FirebaseAuthException If an error occurs while generating the link. */ public String generateVerifyAndChangeEmailLink( @NonNull String email, @NonNull String newEmail, @Nullable ActionCodeSettings settings) throws FirebaseAuthException { - return generateEmailActionLinkOp(EmailLinkType.VERIFY_AND_CHANGE_EMAIL, email, newEmail, - settings).call(); + return generateVerifyAndChangeEmailLinkOp(email, newEmail, settings).call(); } /** - * Similar to {@link #generateVerifyAndChangeEmailLink(String, String)} but performs the operation - * asynchronously. + * Asynchronously generates the out-of-band email action link for the verify and change email + * flow. * - * @param email The email address of the user to be verified. - * @param newEmail The email address to update the user's account to. - * @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 either email address is null or empty. + * @param email The user's current email. + * @param newEmail The user's new email. + * @return An {@code ApiFuture} which will complete with the generated link. + * @throws IllegalArgumentException If email or newEmail are null or empty. */ public ApiFuture generateVerifyAndChangeEmailLinkAsync( @NonNull String email, @NonNull String newEmail) { @@ -1240,24 +1237,33 @@ public ApiFuture generateVerifyAndChangeEmailLinkAsync( } /** - * Similar to {@link #generateVerifyAndChangeEmailLink(String, String, ActionCodeSettings)} but - * performs the operation asynchronously. + * Asynchronously generates the out-of-band email action link for the verify and change email + * flow. * - * @param email The email address of the user to be verified. - * @param newEmail The email address to update the user's account to. - * @param settings The action code settings object 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. - * @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 either email address is null or empty. + * @param email The user's current email. + * @param newEmail The user's new email. + * @param settings The action code settings. + * @return An {@code ApiFuture} which will complete with the generated link. + * @throws IllegalArgumentException If email or newEmail are null or empty. */ public ApiFuture generateVerifyAndChangeEmailLinkAsync( @NonNull String email, @NonNull String newEmail, @Nullable ActionCodeSettings settings) { - return generateEmailActionLinkOp(EmailLinkType.VERIFY_AND_CHANGE_EMAIL, email, newEmail, - settings).callAsync(firebaseApp); + return generateVerifyAndChangeEmailLinkOp(email, newEmail, settings).callAsync(firebaseApp); } + private CallableOperation generateVerifyAndChangeEmailLinkOp( + final String email, final String newEmail, final ActionCodeSettings settings) { + checkArgument(!Strings.isNullOrEmpty(email), "email must not be null or empty"); + checkArgument(!Strings.isNullOrEmpty(newEmail), "newEmail must not be null or empty"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected String execute() throws FirebaseAuthException { + return userManager.getEmailActionLink( + EmailLinkType.VERIFY_AND_CHANGE_EMAIL, email, newEmail, settings); + } + }; + } /** * Generates the out-of-band email action link for email link sign-in flows, using the action code * settings provided.