diff --git a/src/main/java/com/google/firebase/auth/UserRecord.java b/src/main/java/com/google/firebase/auth/UserRecord.java index 0af08f65b..dea8fc94c 100644 --- a/src/main/java/com/google/firebase/auth/UserRecord.java +++ b/src/main/java/com/google/firebase/auth/UserRecord.java @@ -475,6 +475,29 @@ public UpdateRequest setPhoneNumber(@Nullable String phone) { if (phone != null) { checkPhoneNumber(phone); } + + if (phone == null && properties.containsKey("deleteProvider")) { + Object deleteProvider = properties.get("deleteProvider"); + if (deleteProvider != null) { + // Due to java's type erasure, we can't fully check the type. :( + @SuppressWarnings("unchecked") + Iterable deleteProviderIterable = (Iterable)deleteProvider; + + // If we've been told to unlink the phone provider both via setting phoneNumber to null + // *and* by setting providersToUnlink to include 'phone', then we'll reject that. Though + // it might also be reasonable to relax this restriction and just unlink it. + for (String dp : deleteProviderIterable) { + if (dp == "phone") { + throw new IllegalArgumentException( + "Both UpdateRequest.setPhoneNumber(null) and " + + "UpdateRequest.setProvidersToUnlink(['phone']) were set. To unlink from a " + + "phone provider, only specify UpdateRequest.setPhoneNumber(null)."); + + } + } + } + } + properties.put("phoneNumber", phone); return this; } @@ -548,6 +571,52 @@ public UpdateRequest setCustomClaims(Map customClaims) { return this; } + /** + * Links this user to the specified provider. + * + *

Linking a provider to an existing user account does not invalidate the + * refresh token of that account. In other words, the existing account + * would continue to be able to access resources, despite not having used + * the newly linked provider to log in. If you wish to force the user to + * authenticate with this new provider, you need to (a) revoke their + * refresh token (see + * https://firebase.google.com/docs/auth/admin/manage-sessions#revoke_refresh_tokens), + * and (b) ensure no other authentication methods are present on this + * account. + * + * @param providerToLink provider info to be linked to this user\'s account. + */ + public UpdateRequest setProviderToLink(@NonNull UserProvider providerToLink) { + properties.put("linkProviderUserInfo", checkNotNull(providerToLink)); + return this; + } + + /** + * Unlinks this user from the specified providers. + * + * @param providerIds list of identifiers for the identity providers. + */ + public UpdateRequest setProvidersToUnlink(Iterable providerIds) { + checkNotNull(providerIds); + for (String id : providerIds) { + checkArgument(!Strings.isNullOrEmpty(id), "providerIds must not be null or empty"); + + if (id == "phone" && properties.containsKey("phoneNumber") + && properties.get("phoneNumber") == null) { + // If we've been told to unlink the phone provider both via setting phoneNumber to null + // *and* by setting providersToUnlink to include 'phone', then we'll reject that. Though + // it might also be reasonable to relax this restriction and just unlink it. + throw new IllegalArgumentException( + "Both UpdateRequest.setPhoneNumber(null) and " + + "UpdateRequest.setProvidersToUnlink(['phone']) were set. To unlink from a phone " + + "provider, only specify UpdateRequest.setPhoneNumber(null)."); + } + } + + properties.put("deleteProvider", providerIds); + return this; + } + UpdateRequest setValidSince(long epochSeconds) { checkValidSince(epochSeconds); properties.put("validSince", epochSeconds); @@ -569,7 +638,20 @@ Map getProperties(JsonFactory jsonFactory) { } if (copy.containsKey("phoneNumber") && copy.get("phoneNumber") == null) { - copy.put("deleteProvider", ImmutableList.of("phone")); + Object deleteProvider = copy.get("deleteProvider"); + if (deleteProvider != null) { + // Due to java's type erasure, we can't fully check the type. :( + @SuppressWarnings("unchecked") + Iterable deleteProviderIterable = (Iterable)deleteProvider; + + copy.put("deleteProvider", new ImmutableList.Builder() + .addAll(deleteProviderIterable) + .add("phone") + .build()); + } else { + copy.put("deleteProvider", ImmutableList.of("phone")); + } + copy.remove("phoneNumber"); } diff --git a/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java b/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java index 35fa21d4d..7c1da1080 100644 --- a/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java +++ b/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java @@ -18,6 +18,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -307,6 +308,65 @@ public void testUserLifecycle() throws Exception { assertEquals(2, userRecord.getProviderData().length); assertTrue(userRecord.getCustomClaims().isEmpty()); + // Link user to IDP providers + request = userRecord.updateRequest() + .setProviderToLink( + UserProvider + .builder() + .setUid("testuid") + .setProviderId("google.com") + .setEmail("test@example.com") + .setDisplayName("Test User") + .setPhotoUrl("https://test.com/user.png") + .build()); + userRecord = auth.updateUserAsync(request).get(); + assertEquals(uid, userRecord.getUid()); + assertEquals("Updated Name", userRecord.getDisplayName()); + assertEquals(randomUser.getEmail(), userRecord.getEmail()); + assertEquals(randomUser.getPhoneNumber(), userRecord.getPhoneNumber()); + assertEquals("https://example.com/photo.png", userRecord.getPhotoUrl()); + assertTrue(userRecord.isEmailVerified()); + assertFalse(userRecord.isDisabled()); + assertEquals(3, userRecord.getProviderData().length); + List providers = new ArrayList<>(); + for (UserInfo provider : userRecord.getProviderData()) { + providers.add(provider.getProviderId()); + } + assertTrue(providers.contains("google.com")); + assertTrue(userRecord.getCustomClaims().isEmpty()); + + // Unlink phone provider + request = userRecord.updateRequest().setProvidersToUnlink(ImmutableList.of("phone")); + userRecord = auth.updateUserAsync(request).get(); + assertNull(userRecord.getPhoneNumber()); + assertEquals(2, userRecord.getProviderData().length); + providers.clear(); + for (UserInfo provider : userRecord.getProviderData()) { + providers.add(provider.getProviderId()); + } + assertFalse(providers.contains("phone")); + assertEquals(uid, userRecord.getUid()); + assertEquals("Updated Name", userRecord.getDisplayName()); + assertEquals(randomUser.getEmail(), userRecord.getEmail()); + assertEquals("https://example.com/photo.png", userRecord.getPhotoUrl()); + assertTrue(userRecord.isEmailVerified()); + assertFalse(userRecord.isDisabled()); + assertTrue(userRecord.getCustomClaims().isEmpty()); + + // Unlink IDP provider + request = userRecord.updateRequest().setProvidersToUnlink(ImmutableList.of("google.com")); + userRecord = auth.updateUserAsync(request).get(); + assertEquals(1, userRecord.getProviderData().length); + assertNotEquals("google.com", userRecord.getProviderData()[0].getProviderId()); + assertEquals(uid, userRecord.getUid()); + assertEquals("Updated Name", userRecord.getDisplayName()); + assertEquals(randomUser.getEmail(), userRecord.getEmail()); + assertNull(userRecord.getPhoneNumber()); + assertEquals("https://example.com/photo.png", userRecord.getPhotoUrl()); + assertTrue(userRecord.isEmailVerified()); + assertFalse(userRecord.isDisabled()); + assertTrue(userRecord.getCustomClaims().isEmpty()); + // Get user by email userRecord = auth.getUserByEmailAsync(userRecord.getEmail()).get(); assertEquals(uid, userRecord.getUid()); diff --git a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java index 772f9ba8d..9b0a47b2c 100644 --- a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java +++ b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java @@ -84,6 +84,13 @@ public class FirebaseUserManagerTest { private static final Map ACTION_CODE_SETTINGS_MAP = ACTION_CODE_SETTINGS.getProperties(); + private static final UserProvider USER_PROVIDER = UserProvider.builder() + .setUid("testuid") + .setProviderId("facebook.com") + .setEmail("test@example.com") + .setDisplayName("Test User") + .setPhotoUrl("https://test.com/user.png") + .build(); private static final String PROJECT_BASE_URL = "https://identitytoolkit.googleapis.com/v2/projects/test-project-id"; @@ -1091,8 +1098,10 @@ public void testUserUpdater() throws IOException { .setEmailVerified(true) .setPassword("secret") .setCustomClaims(claims) + .setProviderToLink(USER_PROVIDER) + .setProvidersToUnlink(ImmutableList.of("google.com")) .getProperties(JSON_FACTORY); - assertEquals(8, map.size()); + assertEquals(10, map.size()); assertEquals(update.getUid(), map.get("localId")); assertEquals("Display Name", map.get("displayName")); assertEquals("http://test.com/example.png", map.get("photoUrl")); @@ -1101,6 +1110,8 @@ public void testUserUpdater() throws IOException { assertTrue((Boolean) map.get("emailVerified")); assertEquals("secret", map.get("password")); assertEquals(JSON_FACTORY.toString(claims), map.get("customAttributes")); + assertEquals(USER_PROVIDER, map.get("linkProviderUserInfo")); + assertEquals(ImmutableList.of("google.com"), map.get("deleteProvider")); } @Test @@ -1138,6 +1149,64 @@ public void testEmptyCustomClaims() { assertEquals("{}", map.get("customAttributes")); } + @Test + public void testLinkProvider() { + UserRecord.UpdateRequest update = new UserRecord.UpdateRequest("test"); + Map map = update + .setProviderToLink(USER_PROVIDER) + .getProperties(Utils.getDefaultJsonFactory()); + assertEquals(2, map.size()); + assertEquals(update.getUid(), map.get("localId")); + assertEquals(USER_PROVIDER, map.get("linkProviderUserInfo")); + } + + @Test + public void testDeleteProvider() { + UserRecord.UpdateRequest update = new UserRecord.UpdateRequest("test"); + Map map = update + .setProvidersToUnlink(ImmutableList.of("google.com")) + .getProperties(Utils.getDefaultJsonFactory()); + assertEquals(2, map.size()); + assertEquals(update.getUid(), map.get("localId")); + assertEquals(ImmutableList.of("google.com"), map.get("deleteProvider")); + } + + @Test + public void testDeleteProviderAndPhone() { + UserRecord.UpdateRequest update = new UserRecord.UpdateRequest("test"); + Map map = update + .setProvidersToUnlink(ImmutableList.of("google.com")) + .setPhoneNumber(null) + .getProperties(Utils.getDefaultJsonFactory()); + assertEquals(2, map.size()); + assertEquals(update.getUid(), map.get("localId")); + assertEquals(ImmutableList.of("google.com", "phone"), map.get("deleteProvider")); + } + + @Test + public void testDoubleDeletePhoneProvider() throws Exception { + UserRecord.UpdateRequest update = new UserRecord.UpdateRequest("uid") + .setPhoneNumber(null); + + try { + update.setProvidersToUnlink(ImmutableList.of("phone")); + fail("No error thrown for double delete phone provider"); + } catch (IllegalArgumentException expected) { + } + } + + @Test + public void testDoubleDeletePhoneProviderReverseOrder() throws Exception { + UserRecord.UpdateRequest update = new UserRecord.UpdateRequest("uid") + .setProvidersToUnlink(ImmutableList.of("phone")); + + try { + update.setPhoneNumber(null); + fail("No error thrown for double delete phone provider"); + } catch (IllegalArgumentException expected) { + } + } + @Test public void testDeleteDisplayName() { Map map = new UserRecord.UpdateRequest("test")