From 5a4f88690f9d23f6d7fe6992f2bb560bbc51c14d Mon Sep 17 00:00:00 2001 From: Narges Simjour Date: Mon, 27 Jan 2020 16:57:07 -0500 Subject: [PATCH] Add the ability to link/unlink a provider user id to an account. --- .../FirebaseAuthTest.cs | 52 +++++++- .../Auth/FirebaseUserManagerTest.cs | 114 +++++++++++++++++- .../FirebaseAdmin/Auth/ProviderUserInfo.cs | 67 +++++----- .../FirebaseAdmin/Auth/UserRecord.cs | 2 +- .../FirebaseAdmin/Auth/UserRecordArgs.cs | 91 ++++++++++++++ 5 files changed, 289 insertions(+), 37 deletions(-) diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAuthTest.cs b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAuthTest.cs index 6352c159..598e307c 100644 --- a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAuthTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAuthTest.cs @@ -196,8 +196,9 @@ public async Task UserLifecycle() Assert.Empty(user.ProviderData); Assert.Empty(user.CustomClaims); - // Update user + // Update user with new properties as well as a provider to link to the user var randomUser = RandomUser.Create(); + var updateArgs = new UserRecordArgs() { Uid = uid, @@ -207,6 +208,7 @@ public async Task UserLifecycle() PhotoUrl = "https://example.com/photo.png", EmailVerified = true, Password = "secret", + ProviderToLink = randomUser.ProviderUser, }; user = await FirebaseAuth.DefaultInstance.UpdateUserAsync(updateArgs); Assert.Equal(uid, user.Uid); @@ -218,13 +220,49 @@ public async Task UserLifecycle() Assert.False(user.Disabled); Assert.NotNull(user.UserMetaData.CreationTimestamp); Assert.Null(user.UserMetaData.LastSignInTimestamp); - Assert.Equal(2, user.ProviderData.Length); + Assert.Equal(3, user.ProviderData.Length); + var providerIds = new HashSet(); + foreach (ProviderUserInfo providerData in user.ProviderData) + { + providerIds.Add(providerData.ProviderId); + } + + Assert.Equal(providerIds, new HashSet() { "phone", "password", "google.com" }); Assert.Empty(user.CustomClaims); // Get user by email user = await FirebaseAuth.DefaultInstance.GetUserByEmailAsync(randomUser.Email); Assert.Equal(uid, user.Uid); + // Delete the linked provider and phone number + var unlinkArgs = new UserRecordArgs() + { + Uid = uid, + DisplayName = "Updated Name", + Email = randomUser.Email, + PhoneNumber = null, + PhotoUrl = "https://example.com/photo.png", + EmailVerified = true, + Password = "secret", + ProvidersToDelete = new List() + { + randomUser.ProviderUser.ProviderId, + }, + }; + user = await FirebaseAuth.DefaultInstance.UpdateUserAsync(unlinkArgs); + Assert.Equal(uid, user.Uid); + Assert.Equal(randomUser.Email, user.Email); + Assert.Null(user.PhoneNumber); + Assert.Equal("Updated Name", user.DisplayName); + Assert.Equal("https://example.com/photo.png", user.PhotoUrl); + Assert.True(user.EmailVerified); + Assert.False(user.Disabled); + Assert.NotNull(user.UserMetaData.CreationTimestamp); + Assert.Null(user.UserMetaData.LastSignInTimestamp); + Assert.Single(user.ProviderData); + Assert.Equal("password", user.ProviderData.First().ProviderId); + Assert.Empty(user.CustomClaims); + // Disable user and remove properties var disableArgs = new UserRecordArgs() { @@ -245,6 +283,7 @@ public async Task UserLifecycle() Assert.NotNull(user.UserMetaData.CreationTimestamp); Assert.Null(user.UserMetaData.LastSignInTimestamp); Assert.Single(user.ProviderData); + Assert.Equal("password", user.ProviderData.First().ProviderId); Assert.Empty(user.CustomClaims); } finally @@ -409,6 +448,8 @@ internal class RandomUser internal string PhoneNumber { get; private set; } + internal ProviderUserInfo ProviderUser { get; private set; } + internal static RandomUser Create() { var uid = Guid.NewGuid().ToString().Replace("-", string.Empty); @@ -421,11 +462,18 @@ internal static RandomUser Create() phone += rand.Next(10); } + var providerUser = new ProviderUserInfo() + { + Uid = "google_" + uid, + ProviderId = "google.com", + }; + return new RandomUser() { Uid = uid, Email = email, PhoneNumber = phone, + ProviderUser = providerUser, }; } } diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseUserManagerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseUserManagerTest.cs index c8d06450..bf48b482 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseUserManagerTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseUserManagerTest.cs @@ -1032,7 +1032,11 @@ public async Task UpdateUser() { "level", 4 }, { "package", "gold" }, }; - + var providerToLink = new ProviderUserInfo() + { + Uid = "google_user1", + ProviderId = "google.com", + }; var user = await auth.UpdateUserAsync(new UserRecordArgs() { CustomClaims = customClaims, @@ -1044,6 +1048,7 @@ public async Task UpdateUser() PhoneNumber = "+1234567890", PhotoUrl = "https://example.com/user.png", Uid = "user1", + ProviderToLink = providerToLink, }); Assert.Equal("user1", user.Uid); @@ -1057,7 +1062,10 @@ public async Task UpdateUser() Assert.Equal("secret", request["password"]); Assert.Equal("+1234567890", request["phoneNumber"]); Assert.Equal("https://example.com/user.png", request["photoUrl"]); - + var expectedProviderUserInfo = new JObject(); + expectedProviderUserInfo.Add("Uid", "google_user1"); + expectedProviderUserInfo.Add("ProviderId", "google.com"); + Assert.Equal(expectedProviderUserInfo, request["linkProviderUserInfo"]); var claims = NewtonsoftJsonSerializer.Instance.Deserialize((string)request["customAttributes"]); Assert.True((bool)claims["admin"]); Assert.Equal(4L, claims["level"]); @@ -1094,7 +1102,40 @@ public async Task UpdateUserPartial() } [Fact] - public async Task UpdateUserRemoveAttributes() + public async Task UpdateUserLinkProvider() + { + var handler = new MockMessageHandler() + { + Response = new List() { CreateUserResponse, GetUserResponse }, + }; + var auth = this.CreateFirebaseAuth(handler); + + var user = await auth.UpdateUserAsync(new UserRecordArgs() + { + Uid = "user1", + ProviderToLink = new ProviderUserInfo() + { + Uid = "google_user1", + ProviderId = "google.com", + }, + }); + + Assert.Equal("user1", user.Uid); + Assert.Equal(2, handler.Requests.Count); + var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); + Assert.Equal(2, request.Count); + Assert.Equal("user1", request["localId"]); + var expectedProviderUserInfo = new JObject(); + expectedProviderUserInfo.Add("Uid", "google_user1"); + expectedProviderUserInfo.Add("ProviderId", "google.com"); + Assert.Equal(expectedProviderUserInfo, request["linkProviderUserInfo"]); + + this.AssertClientVersion(handler.Requests[0].Headers); + this.AssertClientVersion(handler.Requests[1].Headers); + } + + [Fact] + public async Task UpdateUserDeleteAttributes() { var handler = new MockMessageHandler() { @@ -1123,7 +1164,7 @@ public async Task UpdateUserRemoveAttributes() } [Fact] - public async Task UpdateUserRemoveProviders() + public async Task UpdateUserDeleteProviders() { var handler = new MockMessageHandler() { @@ -1135,6 +1176,7 @@ public async Task UpdateUserRemoveProviders() { PhoneNumber = null, Uid = "user1", + ProvidersToDelete = new List() { "google.com" }, }); Assert.Equal("user1", user.Uid); @@ -1142,9 +1184,9 @@ public async Task UpdateUserRemoveProviders() var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); Assert.Equal(2, request.Count); Assert.Equal("user1", request["localId"]); + Assert.Null(request["phone"]); Assert.Equal( - new JArray() { "phone" }, - request["deleteProvider"]); + new JArray() { "phone", "google.com" }, request["deleteProvider"]); this.AssertClientVersion(handler.Requests[0].Headers); this.AssertClientVersion(handler.Requests[1].Headers); @@ -1393,6 +1435,66 @@ public async Task UpdateUserShortPassword() Assert.Empty(handler.Requests); } + [Fact] + public async Task UpdateUserInvalidProviderToLink() + { + var handler = new MockMessageHandler() { Response = CreateUserResponse }; + var auth = this.CreateFirebaseAuth(handler); + + // Empty provider ID + var args = new UserRecordArgs() + { + ProviderToLink = new ProviderUserInfo() + { + Uid = "google_user1", + ProviderId = string.Empty, + }, + Uid = "user1", + }; + await Assert.ThrowsAsync( + async () => await auth.UpdateUserAsync(args)); + Assert.Empty(handler.Requests); + + // Empty provider UID + args.ProviderToLink.Uid = string.Empty; + args.ProviderToLink.ProviderId = "google.com"; + await Assert.ThrowsAsync( + async () => await auth.UpdateUserAsync(args)); + Assert.Empty(handler.Requests); + + // Phone provider updates in two places + args.PhoneNumber = "+11234567890"; + args.ProviderToLink.ProviderId = "phone"; + args.ProviderToLink.Uid = "+11234567891"; + await Assert.ThrowsAsync( + async () => await auth.UpdateUserAsync(args)); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task UpdateUserInvalidProvidersToDelete() + { + var handler = new MockMessageHandler() { Response = CreateUserResponse }; + var auth = this.CreateFirebaseAuth(handler); + + // Empty provider ID + var args = new UserRecordArgs() + { + ProvidersToDelete = new List() { "google.com", string.Empty }, + Uid = "user1", + }; + await Assert.ThrowsAsync( + async () => await auth.UpdateUserAsync(args)); + Assert.Empty(handler.Requests); + + // Phone provider updates in two places + args.PhoneNumber = null; + args.ProvidersToDelete = new List() { "google.com", "phone" }; + await Assert.ThrowsAsync( + async () => await auth.UpdateUserAsync(args)); + Assert.Empty(handler.Requests); + } + [Fact] public void EmptyNameClaims() { diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/ProviderUserInfo.cs b/FirebaseAdmin/FirebaseAdmin/Auth/ProviderUserInfo.cs index 1fef1f1d..4dad8455 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/ProviderUserInfo.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/ProviderUserInfo.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Text; +using Google.Apis.Json; +using Newtonsoft.Json; namespace FirebaseAdmin.Auth { @@ -8,57 +10,66 @@ namespace FirebaseAdmin.Auth /// Contains metadata regarding how a user is known by a particular identity provider (IdP). /// Instances of this class are immutable and thread safe. /// - internal sealed class ProviderUserInfo : IUserInfo + public sealed class ProviderUserInfo : IUserInfo { /// - /// Initializes a new instance of the class with data provided by an authentication provider. - /// - /// The deserialized JSON user data from the provider. - internal ProviderUserInfo(GetAccountInfoResponse.Provider provider) - { - this.Uid = provider.UserId; - this.DisplayName = provider.DisplayName; - this.Email = provider.Email; - this.PhoneNumber = provider.PhoneNumber; - this.PhotoUrl = provider.PhotoUrl; - this.ProviderId = provider.ProviderID; - } - - /// - /// Gets the user's unique ID assigned by the identity provider. + /// Gets or sets the user's unique ID assigned by the identity provider. /// /// a user ID string. - public string Uid { get; private set; } + [JsonProperty("rawId")] + public string Uid { get; set; } /// - /// Gets the user's display name, if available. + /// Gets or sets the user's display name, if available. /// /// a display name string or null. - public string DisplayName { get; private set; } + [JsonProperty("displayName")] + public string DisplayName { get; set; } /// - /// Gets the user's email address, if available. + /// Gets or sets the user's email address, if available. /// /// an email address string or null. - public string Email { get; private set; } + [JsonProperty("email")] + public string Email { get; set; } /// - /// Gets the user's phone number. + /// Gets or sets the user's phone number. /// /// a phone number string or null. - public string PhoneNumber { get; private set; } + [JsonProperty("phoneNumber")] + public string PhoneNumber { get; set; } /// - /// Gets the user's photo URL, if available. + /// Gets or sets the user's photo URL, if available. /// /// a URL string or null. - public string PhotoUrl { get; private set; } + [JsonProperty("photoUrl")] + public string PhotoUrl { get; set; } /// - /// Gets the ID of the identity provider. This can be a short domain name (e.g. google.com) or - /// the identifier of an OpenID identity provider. + /// Gets or sets the ID of the identity provider. This can be a short domain name (e.g. + /// google.com) or the identifier of an OpenID identity provider. /// /// an ID string that uniquely identifies the identity provider. - public string ProviderId { get; private set; } + [JsonProperty("providerId")] + public string ProviderId { get; set; } + + /// + /// Initializes a new instance of the class with data provided by an authentication provider. + /// + /// The deserialized JSON user data from the provider. + internal static ProviderUserInfo Create(GetAccountInfoResponse.Provider provider) + { + return new ProviderUserInfo() + { + Uid = provider.UserId, + DisplayName = provider.DisplayName, + Email = provider.Email, + PhoneNumber = provider.PhoneNumber, + PhotoUrl = provider.PhotoUrl, + ProviderId = provider.ProviderID, + }; + } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs index 0ce76733..93d30f47 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs @@ -66,7 +66,7 @@ internal UserRecord(GetAccountInfoResponse.User user) this.ProviderData = new IUserInfo[count]; for (int i = 0; i < count; i++) { - this.ProviderData[i] = new ProviderUserInfo(user.Providers[i]); + this.ProviderData[i] = ProviderUserInfo.Create(user.Providers[i]); } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs index fc7f01d3..0eadd468 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs @@ -30,6 +30,8 @@ public sealed class UserRecordArgs private Optional photoUrl; private Optional phoneNumber; private Optional> customClaims; + private Optional providerToLink; + private Optional> providersToDelete; private bool? disabled = null; private bool? emailVerified = null; @@ -93,6 +95,24 @@ public bool Disabled /// public string Password { get; set; } + /// + /// Gets or sets the user's provider info to be linked to the user account. + /// + public ProviderUserInfo ProviderToLink + { + get => this.providerToLink?.Value; + set => this.providerToLink = this.Wrap(value); + } + + /// + /// Gets or sets IDs of providers to be unlinked from the user account. + /// + public IList ProvidersToDelete + { + get => this.providersToDelete?.Value; + set => this.providersToDelete = this.Wrap(value); + } + internal IReadOnlyDictionary CustomClaims { get => this.customClaims?.Value; @@ -225,6 +245,27 @@ private static string CheckCustomClaims(IReadOnlyDictionary clai return customClaimsString; } + private static string CheckProviderId(string providerId) + { + if (providerId == null || providerId == string.Empty) + { + throw new ArgumentException("Provider ID must not be empty"); + } + + return providerId; + } + + private static ProviderUserInfo CheckProviderUserInfo(ProviderUserInfo providerUserInfo) + { + CheckProviderId(providerUserInfo.ProviderId); + if (providerUserInfo.Uid == null || providerUserInfo.Uid == string.Empty) + { + throw new ArgumentException("Provider user ID must not be empty"); + } + + return providerUserInfo; + } + private Optional Wrap(T value) { return new Optional(value); @@ -242,6 +283,10 @@ internal CreateUserRequest(UserRecordArgs args) this.PhoneNumber = CheckPhoneNumber(args.PhoneNumber); this.PhotoUrl = CheckPhotoUrl(args.PhotoUrl); this.Uid = CheckUid(args.Uid); + if (args.ProviderToLink != null) + { + throw new ArgumentException("ProviderToLink must be null in CreateUserRequests."); + } } [JsonProperty("disabled")] @@ -273,6 +318,9 @@ internal sealed class UpdateUserRequest { internal UpdateUserRequest(UserRecordArgs args) { + // Keeping track of provider IDs being updated, to make sure a single ID + // is not updated in two places. + var providerIdsToUpdate = new List(); this.Uid = CheckUid(args.Uid, required: true); if (args.customClaims != null) { @@ -312,6 +360,7 @@ internal UpdateUserRequest(UserRecordArgs args) if (args.phoneNumber != null) { + providerIdsToUpdate.Add("phone"); var phoneNumber = args.phoneNumber.Value; if (phoneNumber == null) { @@ -322,6 +371,31 @@ internal UpdateUserRequest(UserRecordArgs args) this.PhoneNumber = CheckPhoneNumber(phoneNumber); } } + + if (args.providersToDelete != null) + { + var providersToDelete = args.providersToDelete.Value; + if (providersToDelete != null) + { + foreach (string providerToDelete in providersToDelete) + { + this.AddDeleteProvider(CheckProviderId(providerToDelete)); + providerIdsToUpdate.Add(providerToDelete); + } + } + } + + if (args.providerToLink != null) + { + var providerToLink = args.providerToLink.Value; + if (providerToLink != null) + { + this.ProviderToLink = CheckProviderUserInfo(providerToLink); + providerIdsToUpdate.Add(this.ProviderToLink.ProviderId); + } + } + + this.AssertDistinctProviderIds(providerIdsToUpdate); } [JsonProperty("customAttributes")] @@ -357,6 +431,9 @@ internal UpdateUserRequest(UserRecordArgs args) [JsonProperty("localId")] public string Uid { get; set; } + [JsonProperty("linkProviderUserInfo")] + public ProviderUserInfo ProviderToLink { get; set; } + private void AddDeleteAttribute(string attribute) { if (this.DeleteAttribute == null) @@ -376,6 +453,20 @@ private void AddDeleteProvider(string provider) this.DeleteProvider.Add(provider); } + + private void AssertDistinctProviderIds(List providerIds) + { + var iteratedIds = new HashSet(); + foreach (string providerId in providerIds) + { + if (iteratedIds.Contains(providerId)) + { + throw new ArgumentException("Update request includes more than one update for Provider ID " + providerId); + } + + iteratedIds.Add(providerId); + } + } } ///