diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs index 84a37494..6a76bbb3 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs @@ -34,22 +34,40 @@ namespace FirebaseAdmin.Auth.Users.Tests { public class FirebaseUserManagerTest { - public static readonly IEnumerable TestConfigs = new List() + public static readonly TheoryData TestConfigs = new TheoryData() { - new object[] { TestConfig.ForFirebaseAuth() }, - new object[] { TestConfig.ForTenantAwareFirebaseAuth("tenant1") }, - new object[] { TestConfig.ForFirebaseAuth().WithEmulator() }, - new object[] { TestConfig.ForTenantAwareFirebaseAuth("tenant1").WithEmulator() }, + TestConfig.ForFirebaseAuth(), + TestConfig.ForTenantAwareFirebaseAuth("tenant1"), + TestConfig.ForFirebaseAuth().WithEmulator(), + TestConfig.ForTenantAwareFirebaseAuth("tenant1").WithEmulator(), }; - public static readonly IEnumerable MainTenantTestConfigs = new List() + public static readonly TheoryData MainTenantTestConfigs = new TheoryData() { - new object[] { TestConfig.ForFirebaseAuth() }, - new object[] { TestConfig.ForFirebaseAuth().WithEmulator() }, + TestConfig.ForFirebaseAuth(), + TestConfig.ForFirebaseAuth().WithEmulator(), }; private const string CreateUserResponse = @"{""localId"": ""user1""}"; + public static TheoryData UpdateUserInvalidProviderToLinkTestData + { + get + { + var data = new TheoryData(); + + foreach (var testConfigObj in TestConfigs) + { + var testConfig = (TestConfig)testConfigObj[0]; + + data.Add(testConfig, "google_user1", string.Empty); // Empty provider ID + data.Add(testConfig, string.Empty, "google.com"); // Empty provider UID + } + + return data; + } + } + [Theory] [MemberData(nameof(TestConfigs))] public async Task GetUserById(TestConfig config) @@ -1107,6 +1125,12 @@ public async Task UpdateUser(TestConfig config) { "package", "gold" }, }; + var providerToLink = new ProviderUserInfoArgs() + { + Uid = "google_user1", + ProviderId = "google.com", + }; + var user = await auth.UpdateUserAsync(new UserRecordArgs() { CustomClaims = customClaims, @@ -1118,6 +1142,7 @@ public async Task UpdateUser(TestConfig config) PhoneNumber = "+1234567890", PhotoUrl = "https://example.com/user.png", Uid = "user1", + ProviderToLink = providerToLink, }); Assert.Equal("user1", user.Uid); @@ -1135,6 +1160,13 @@ public async Task UpdateUser(TestConfig config) Assert.Equal("+1234567890", request["phoneNumber"]); Assert.Equal("https://example.com/user.png", request["photoUrl"]); + var expectedProviderUserInfo = new JObject + { + { "Uid", "google_user1" }, + { "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"]); @@ -1168,6 +1200,37 @@ public async Task UpdateUserPartial(TestConfig config) Assert.True((bool)request["emailVerified"]); } + [Theory] + [MemberData(nameof(TestConfigs))] + public async Task UpdateUserLinkProvider(TestConfig config) + { + var handler = new MockMessageHandler() + { + Response = new List() { CreateUserResponse, config.GetUserResponse() }, + }; + var auth = config.CreateAuth(handler); + + var user = await auth.UpdateUserAsync(new UserRecordArgs() + { + Uid = "user1", + ProviderToLink = new ProviderUserInfoArgs() + { + 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"]); + } + [Theory] [MemberData(nameof(TestConfigs))] public async Task UpdateUserRemoveAttributes(TestConfig config) @@ -1212,6 +1275,7 @@ public async Task UpdateUserRemoveProviders(TestConfig config) { PhoneNumber = null, Uid = "user1", + ProvidersToDelete = new List() { "google.com" }, }); Assert.Equal("user1", user.Uid); @@ -1223,7 +1287,7 @@ public async Task UpdateUserRemoveProviders(TestConfig config) Assert.Equal(2, request.Count); Assert.Equal("user1", request["localId"]); Assert.Equal( - new JArray() { "phone" }, + new JArray() { "phone", "google.com" }, request["deleteProvider"]); } @@ -1485,6 +1549,110 @@ public async Task UpdateUserShortPassword(TestConfig config) Assert.Empty(handler.Requests); } + [Theory] + [MemberData(nameof(UpdateUserInvalidProviderToLinkTestData))] + public async Task UpdateUserInvalidProviderToLink(TestConfig config, string uid, string providerId) + { + var handler = new MockMessageHandler() { Response = CreateUserResponse }; + var auth = config.CreateAuth(handler); + + var args = new UserRecordArgs() + { + ProviderToLink = new ProviderUserInfoArgs() + { + Uid = uid, + ProviderId = providerId, + }, + Uid = "user1", + }; + await Assert.ThrowsAsync( + async () => await auth.UpdateUserAsync(args)); + Assert.Empty(handler.Requests); + } + + [Theory] + [MemberData(nameof(TestConfigs))] + public async Task UpdateUserInvalidEmailProviderToLink(TestConfig config) + { + var handler = new MockMessageHandler() { Response = CreateUserResponse }; + var auth = config.CreateAuth(handler); + + // Phone provider updated in 2 places in the same request + var args = new UserRecordArgs() + { + ProviderToLink = new ProviderUserInfoArgs() + { + Uid = "foo@bar.com", + ProviderId = "email", + }, + Uid = "user1", + Email = "foo@bar.com", + }; + var exception = await Assert.ThrowsAsync( + async () => await auth.UpdateUserAsync(args)); + + const string expectedError = "Both UpdateRequest.Email and UpdateRequest.ProviderToLink.ProviderId='email' " + + "were set. To link to the email/password provider, only specify the " + + "UpdateRequest.Email field."; + + Assert.Equal(expectedError, exception.Message); + Assert.Empty(handler.Requests); + } + + [Theory] + [MemberData(nameof(TestConfigs))] + public async Task UpdateUserInvalidPhoneProviderToLink(TestConfig config) + { + var handler = new MockMessageHandler() { Response = CreateUserResponse }; + var auth = config.CreateAuth(handler); + + // Phone provider updated in 2 places in the same request + var args = new UserRecordArgs() + { + ProviderToLink = new ProviderUserInfoArgs() + { + Uid = "+11234567891", + ProviderId = "phone", + }, + Uid = "user1", + PhoneNumber = "+11234567891", + }; + var exception = await Assert.ThrowsAsync( + async () => await auth.UpdateUserAsync(args)); + + const string expectedError = "Both UpdateRequest.PhoneNumber and UpdateRequest.ProviderToLink.ProviderId='phone'" + + " were set. To link to a phone provider, only specify the " + + "UpdateRequest.PhoneNumber field."; + + Assert.Equal(expectedError, exception.Message); + Assert.Empty(handler.Requests); + } + + [Theory] + [MemberData(nameof(TestConfigs))] + public async Task UpdateUserInvalidProvidersToDelete(TestConfig config) + { + var handler = new MockMessageHandler() { Response = CreateUserResponse }; + var auth = config.CreateAuth(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); + } + [Theory] [MemberData(nameof(TestConfigs))] public void EmptyNameClaims(TestConfig config) diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/ProviderIdentifier.cs b/FirebaseAdmin/FirebaseAdmin/Auth/ProviderIdentifier.cs index 88d22882..01cbbca2 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/ProviderIdentifier.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/ProviderIdentifier.cs @@ -35,7 +35,7 @@ public sealed class ProviderIdentifier : UserIdentifier /// The providerUid. public ProviderIdentifier(string providerId, string providerUid) { - UserRecordArgs.CheckProvider(providerId, providerUid, required: true); + UserRecordArgs.CheckProvider(providerId, providerUid, true, true); this.providerId = providerId; this.providerUid = providerUid; } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/ProviderUserInfo.cs b/FirebaseAdmin/FirebaseAdmin/Auth/ProviderUserInfo.cs index 821288d7..e7da295c 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/ProviderUserInfo.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/ProviderUserInfo.cs @@ -13,6 +13,7 @@ // limitations under the License. using FirebaseAdmin.Auth.Users; +using Newtonsoft.Json; namespace FirebaseAdmin.Auth { @@ -36,41 +37,61 @@ internal ProviderUserInfo(GetAccountInfoResponse.Provider provider) this.ProviderId = provider.ProviderID; } + /// + /// Initializes a new instance of the class. + /// + /// Arguments that describe the new provider user info. + internal ProviderUserInfo(ProviderUserInfoArgs args) + { + this.Uid = args.Uid; + this.DisplayName = args.DisplayName; + this.Email = args.Email; + this.PhoneNumber = args.PhoneNumber; + this.PhotoUrl = args.PhotoUrl; + this.ProviderId = args.ProviderId; + } + /// /// Gets the user's unique ID assigned by the identity provider. /// /// a user ID string. - public string Uid { get; private set; } + [JsonProperty(PropertyName = "rawId")] + public string Uid { get; } /// /// Gets the user's display name, if available. /// /// a display name string or null. - public string DisplayName { get; private set; } + [JsonProperty(PropertyName = "displayName")] + public string DisplayName { get; } /// /// Gets the user's email address, if available. /// /// an email address string or null. - public string Email { get; private set; } + [JsonProperty(PropertyName = "email")] + public string Email { get; } /// - /// Gets the user's phone number. + /// Gets the user's phone number, if available. /// /// a phone number string or null. - public string PhoneNumber { get; private set; } + [JsonProperty(PropertyName = "phoneNumber")] + public string PhoneNumber { get; } /// /// Gets the user's photo URL, if available. /// /// a URL string or null. - public string PhotoUrl { get; private set; } + [JsonProperty(PropertyName = "photoUrl")] + public string PhotoUrl { get; } /// /// 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. /// /// an ID string that uniquely identifies the identity provider. - public string ProviderId { get; private set; } + [JsonProperty(PropertyName = "providerId")] + public string ProviderId { get; } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/ProviderUserInfoArgs.cs b/FirebaseAdmin/FirebaseAdmin/Auth/ProviderUserInfoArgs.cs new file mode 100644 index 00000000..dd8d21e2 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Auth/ProviderUserInfoArgs.cs @@ -0,0 +1,59 @@ +// Copyright 2023, Google Inc. All rights reserved. +// +// 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. + +namespace FirebaseAdmin.Auth +{ + /// + /// Contains metadata regarding how a user is known by a particular identity provider (IdP). + /// + public sealed class ProviderUserInfoArgs + { + /// + /// Gets or sets the user's unique ID assigned by the identity provider. + /// + /// a user ID string. + public string Uid { get; set; } + + /// + /// Gets or sets the user's display name, if available. + /// + /// a display name string or null. + public string DisplayName { get; set; } + + /// + /// Gets or sets the user's email address, if available. + /// + /// an email address string or null. + public string Email { get; set; } + + /// + /// Gets or sets the user's phone number. + /// + /// a phone number string or null. + public string PhoneNumber { get; set; } + + /// + /// Gets or sets the user's photo URL, if available. + /// + /// a URL string or null. + public string PhotoUrl { get; set; } + + /// + /// 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; set; } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs index 433deb1b..44c7863d 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs @@ -94,6 +94,16 @@ public bool Disabled /// public string Password { get; set; } + /// + /// Gets or sets the user's provider info to be linked to the user account. + /// + public ProviderUserInfoArgs ProviderToLink { get; set; } + + /// + /// Gets or sets IDs of providers to be unlinked from the user account. + /// + public IEnumerable ProvidersToDelete { get; set; } + internal long? ValidSince { get; set; } internal IReadOnlyDictionary CustomClaims @@ -169,11 +179,11 @@ internal static string CheckPhoneNumber(string phoneNumber, bool required = fals // TODO(rsgowman): Once we upgrade our floor from .NET4.5 to .NET4.7, we can return a tuple // here, making this more like the other CheckX methods. i.e.: // internal static (string, string) CheckProvider(...) - internal static void CheckProvider(string providerId, string providerUid, bool required = false) + internal static void CheckProvider(string providerId, string providerUid, bool providerIdRequired, bool providerUidRequired) { if (providerId == null) { - if (required) + if (providerIdRequired) { throw new ArgumentNullException(nameof(providerId)); } @@ -185,7 +195,7 @@ internal static void CheckProvider(string providerId, string providerUid, bool r if (providerUid == null) { - if (required) + if (providerUidRequired) { throw new ArgumentNullException(nameof(providerUid)); } @@ -349,11 +359,16 @@ 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 HashSet(); + if (args.phoneNumber != null) { var phoneNumber = args.phoneNumber.Value; if (phoneNumber == null) { + providerIdsToUpdate.Add("phone"); this.AddDeleteProvider("phone"); } else @@ -361,6 +376,61 @@ internal UpdateUserRequest(UserRecordArgs args) this.PhoneNumber = CheckPhoneNumber(phoneNumber); } } + + if (args.ProvidersToDelete != null) + { + foreach (var providerToDelete in args.ProvidersToDelete) + { + CheckProvider(providerToDelete, null, true, false); + + if (!providerIdsToUpdate.Add(providerToDelete)) + { + throw new ArgumentException( + $"Update request includes more than one update for Provider ID '{providerToDelete}'"); + } + + this.AddDeleteProvider(providerToDelete); + } + } + + if (args.ProviderToLink != null) + { + CheckProvider(args.ProviderToLink.ProviderId, args.ProviderToLink.Uid, true, true); + this.ProviderToLink = new ProviderUserInfo(args.ProviderToLink); + + var providerIdToLink = this.ProviderToLink.ProviderId; + + if (!providerIdsToUpdate.Add(providerIdToLink)) + { + throw new ArgumentException( + $"Update request includes more than one update for Provider ID '{providerIdToLink}'"); + } + + if (providerIdToLink == "email") + { + if (!string.IsNullOrEmpty(this.Email)) + { + throw new FirebaseAuthException( + ErrorCode.InvalidArgument, + "Both UpdateRequest.Email and UpdateRequest.ProviderToLink.ProviderId='email' were set. To link to the email/password provider, only specify the UpdateRequest.Email field."); + } + + this.Email = this.ProviderToLink.Uid; + this.ProviderToLink = null; + } + else if (providerIdToLink == "phone") + { + if (!string.IsNullOrEmpty(this.PhoneNumber)) + { + throw new FirebaseAuthException( + ErrorCode.InvalidArgument, + "Both UpdateRequest.PhoneNumber and UpdateRequest.ProviderToLink.ProviderId='phone' were set. To link to a phone provider, only specify the UpdateRequest.PhoneNumber field."); + } + + this.PhoneNumber = this.ProviderToLink.Uid; + this.ProviderToLink = null; + } + } } [JsonProperty("customAttributes")] @@ -399,6 +469,9 @@ internal UpdateUserRequest(UserRecordArgs args) [JsonProperty("validSince")] public long? ValidSince { get; set; } + [JsonProperty("linkProviderUserInfo")] + public ProviderUserInfo ProviderToLink { get; set; } + private void AddDeleteAttribute(string attribute) { if (this.DeleteAttribute == null)