Skip to content

Commit 374ac19

Browse files
authored
feat(auth): Add bulk get/delete methods (#151)
This PR allows callers to retrieve a list of users by unique identifier (uid, email, phone, federated provider uid) as well as to delete a list of users. RELEASE NOTE: Added GetUsersAsync() and DeleteUsersAsync() APIs for retrieving and deleting user accounts in bulk.
1 parent 0f42f93 commit 374ac19

18 files changed

+1315
-30
lines changed

FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAuthTest.cs

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
using System.Linq;
1818
using System.Net.Http;
1919
using System.Text;
20+
using System.Threading;
2021
using System.Threading.Tasks;
2122
using System.Web;
2223
using FirebaseAdmin.Auth;
@@ -37,6 +38,9 @@ public class FirebaseAuthTest
3738
private const string VerifyCustomTokenUrl =
3839
"https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken";
3940

41+
private const string VerifyPasswordUrl =
42+
"https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword";
43+
4044
private const string ContinueUrl = "http://localhost/?a=1&b=2#c=3";
4145

4246
private static readonly ActionCodeSettings EmailLinkSettings = new ActionCodeSettings()
@@ -336,6 +340,37 @@ public async Task GetUserNonExistingEmail()
336340
Assert.Equal(AuthErrorCode.UserNotFound, exception.AuthErrorCode);
337341
}
338342

343+
[Fact]
344+
public async Task LastRefreshTime()
345+
{
346+
var newUserRecord = await NewUserWithParamsAsync();
347+
try
348+
{
349+
// New users should not have a LastRefreshTimestamp set.
350+
Assert.Null(newUserRecord.UserMetaData.LastRefreshTimestamp);
351+
352+
// Login to cause the LastRefreshTimestamp to be set.
353+
await SignInWithPasswordAsync(newUserRecord.Email, "password");
354+
355+
var userRecord = await FirebaseAuth.DefaultInstance.GetUserAsync(newUserRecord.Uid);
356+
357+
// Ensure the LastRefreshTimstamp is approximately "now" (with a tollerance of 10 minutes).
358+
var now = DateTime.UtcNow;
359+
int tolleranceMinutes = 10;
360+
var minTime = now.AddMinutes(-tolleranceMinutes);
361+
var maxTime = now.AddMinutes(tolleranceMinutes);
362+
Assert.NotNull(userRecord.UserMetaData.LastRefreshTimestamp);
363+
Assert.InRange(
364+
userRecord.UserMetaData.LastRefreshTimestamp.Value,
365+
minTime,
366+
maxTime);
367+
}
368+
finally
369+
{
370+
await FirebaseAuth.DefaultInstance.DeleteUserAsync(newUserRecord.Uid);
371+
}
372+
}
373+
339374
[Fact]
340375
public async Task UpdateUserNonExistingUid()
341376
{
@@ -359,6 +394,76 @@ public async Task DeleteUserNonExistingUid()
359394
Assert.Equal(AuthErrorCode.UserNotFound, exception.AuthErrorCode);
360395
}
361396

397+
[Fact]
398+
public async Task DeleteUsers()
399+
{
400+
UserRecord user1 = await NewUserWithParamsAsync();
401+
UserRecord user2 = await NewUserWithParamsAsync();
402+
UserRecord user3 = await NewUserWithParamsAsync();
403+
404+
DeleteUsersResult deleteUsersResult = await this.SlowDeleteUsersAsync(
405+
new List<string> { user1.Uid, user2.Uid, user3.Uid });
406+
407+
Assert.Equal(3, deleteUsersResult.SuccessCount);
408+
Assert.Equal(0, deleteUsersResult.FailureCount);
409+
Assert.Empty(deleteUsersResult.Errors);
410+
411+
GetUsersResult getUsersResult = await FirebaseAuth.DefaultInstance.GetUsersAsync(
412+
new List<UserIdentifier>
413+
{
414+
new UidIdentifier(user1.Uid),
415+
new UidIdentifier(user2.Uid),
416+
new UidIdentifier(user3.Uid),
417+
});
418+
419+
Assert.Empty(getUsersResult.Users);
420+
Assert.Equal(3, getUsersResult.NotFound.Count());
421+
}
422+
423+
[Fact]
424+
public async Task DeleteExistingAndNonExistingUsers()
425+
{
426+
UserRecord user1 = await NewUserWithParamsAsync();
427+
428+
DeleteUsersResult deleteUsersResult = await this.SlowDeleteUsersAsync(
429+
new List<string> { user1.Uid, "uid-that-doesnt-exist" });
430+
431+
Assert.Equal(2, deleteUsersResult.SuccessCount);
432+
Assert.Equal(0, deleteUsersResult.FailureCount);
433+
Assert.Empty(deleteUsersResult.Errors);
434+
435+
GetUsersResult getUsersResult = await FirebaseAuth.DefaultInstance.GetUsersAsync(
436+
new List<UserIdentifier>
437+
{
438+
new UidIdentifier(user1.Uid),
439+
new UidIdentifier("uid-that-doesnt-exist"),
440+
});
441+
442+
Assert.Empty(getUsersResult.Users);
443+
Assert.Equal(2, getUsersResult.NotFound.Count());
444+
}
445+
446+
[Fact]
447+
public async Task DeleteUsersIsIdempotent()
448+
{
449+
UserRecord user1 = await NewUserWithParamsAsync();
450+
451+
DeleteUsersResult result = await this.SlowDeleteUsersAsync(
452+
new List<string> { user1.Uid });
453+
454+
Assert.Equal(1, result.SuccessCount);
455+
Assert.Equal(0, result.FailureCount);
456+
Assert.Empty(result.Errors);
457+
458+
// Delete the user again, ensuring that everything still counts as a success.
459+
result = await this.SlowDeleteUsersAsync(
460+
new List<string> { user1.Uid });
461+
462+
Assert.Equal(1, result.SuccessCount);
463+
Assert.Equal(0, result.FailureCount);
464+
Assert.Empty(result.Errors);
465+
}
466+
362467
[Fact]
363468
public async Task ListUsers()
364469
{
@@ -520,6 +625,24 @@ public async Task SessionCookie()
520625
Assert.Equal("testuser", decoded.Uid);
521626
}
522627

628+
private static async Task<UserRecord> NewUserWithParamsAsync()
629+
{
630+
// TODO(rsgowman): This function could be used throughout this file
631+
// (similar to the other ports).
632+
RandomUser randomUser = RandomUser.Create();
633+
var args = new UserRecordArgs()
634+
{
635+
Uid = randomUser.Uid,
636+
Email = randomUser.Email,
637+
PhoneNumber = randomUser.PhoneNumber,
638+
DisplayName = "Random User",
639+
PhotoUrl = "https://example.com/photo.png",
640+
Password = "password",
641+
};
642+
643+
return await FirebaseAuth.DefaultInstance.CreateUserAsync(args);
644+
}
645+
523646
private static async Task<UserRecord> CreateUserForActionLinksAsync()
524647
{
525648
var randomUser = RandomUser.Create();
@@ -558,6 +681,33 @@ private static async Task<string> SignInWithCustomTokenAsync(string customToken)
558681
}
559682
}
560683

684+
private static async Task<string> SignInWithPasswordAsync(string email, string password)
685+
{
686+
var rb = new Google.Apis.Requests.RequestBuilder()
687+
{
688+
Method = Google.Apis.Http.HttpConsts.Post,
689+
BaseUri = new Uri(VerifyPasswordUrl),
690+
};
691+
rb.AddParameter(RequestParameterType.Query, "key", IntegrationTestUtils.GetApiKey());
692+
var request = rb.CreateRequest();
693+
var jsonSerializer = Google.Apis.Json.NewtonsoftJsonSerializer.Instance;
694+
var payload = jsonSerializer.Serialize(new VerifyPasswordRequest
695+
{
696+
Email = email,
697+
Password = password,
698+
ReturnSecureToken = true,
699+
});
700+
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
701+
using (var client = new HttpClient())
702+
{
703+
var response = await client.SendAsync(request);
704+
response.EnsureSuccessStatusCode();
705+
var json = await response.Content.ReadAsStringAsync();
706+
var parsed = jsonSerializer.Deserialize<VerifyPasswordResponse>(json);
707+
return parsed.IdToken;
708+
}
709+
}
710+
561711
private static async Task<string> SignInWithEmailLinkAsync(string email, string oobCode)
562712
{
563713
var rb = new Google.Apis.Requests.RequestBuilder()
@@ -610,6 +760,132 @@ private static async Task<string> ResetPasswordAsync(ResetPasswordRequest data)
610760
return (string)parsed["email"];
611761
}
612762
}
763+
764+
/**
765+
* The {@code batchDelete} endpoint is currently rate limited to 1qps. Use this test helper
766+
* to ensure you don't run into quota exceeded errors.
767+
*/
768+
// TODO(rsgowman): When/if the rate limit is relaxed, eliminate this helper.
769+
private async Task<DeleteUsersResult> SlowDeleteUsersAsync(IReadOnlyList<string> uids)
770+
{
771+
await Task.Delay(millisecondsDelay: 1000);
772+
return await FirebaseAuth.DefaultInstance.DeleteUsersAsync(uids);
773+
}
774+
775+
public class GetUsersFixture : IDisposable
776+
{
777+
public GetUsersFixture()
778+
{
779+
IntegrationTestUtils.EnsureDefaultApp();
780+
781+
this.TestUser1 = NewUserWithParamsAsync().Result;
782+
this.TestUser2 = NewUserWithParamsAsync().Result;
783+
this.TestUser3 = NewUserWithParamsAsync().Result;
784+
785+
// The C# port doesn't support importing users, so unlike the other ports, there's
786+
// no way to create a user with a linked federated provider.
787+
// TODO(rsgowman): Once either FirebaseAuth.ImportUser() exists (or the UpdateUser()
788+
// method supports ProviderToLink (#143)), then use it here and
789+
// adjust the VariousIdentifiers() test below.
790+
}
791+
792+
public UserRecord TestUser1 { get; }
793+
794+
public UserRecord TestUser2 { get; }
795+
796+
public UserRecord TestUser3 { get; }
797+
798+
public void Dispose()
799+
{
800+
// TODO(rsgowman): deleteUsers (plural) would make more sense here, but it's
801+
// currently rate limited to 1qps. When/if that's relaxed, change this to just
802+
// delete them all at once.
803+
var auth = FirebaseAuth.DefaultInstance;
804+
auth.DeleteUserAsync(this.TestUser1.Uid).Wait();
805+
auth.DeleteUserAsync(this.TestUser2.Uid).Wait();
806+
auth.DeleteUserAsync(this.TestUser3.Uid).Wait();
807+
}
808+
}
809+
810+
public class GetUsers : IClassFixture<GetUsersFixture>
811+
{
812+
private FirebaseAuth auth;
813+
private UserRecord testUser1;
814+
private UserRecord testUser2;
815+
private UserRecord testUser3;
816+
817+
public GetUsers(GetUsersFixture fixture)
818+
{
819+
this.auth = FirebaseAuth.DefaultInstance;
820+
this.testUser1 = fixture.TestUser1;
821+
this.testUser2 = fixture.TestUser2;
822+
this.testUser3 = fixture.TestUser3;
823+
}
824+
825+
[Fact]
826+
public async void VariousIdentifiers()
827+
{
828+
var getUsersResult = await this.auth.GetUsersAsync(new List<UserIdentifier>()
829+
{
830+
new UidIdentifier(this.testUser1.Uid),
831+
new EmailIdentifier(this.testUser2.Email),
832+
new PhoneIdentifier(this.testUser3.PhoneNumber),
833+
// TODO(rsgowman): Once we're able to create a user with a
834+
// provider, do so above and fetch the user like this:
835+
// new ProviderIdentifier("google.com", "google_" + importUserUid),
836+
});
837+
838+
var uids = getUsersResult.Users.Select(userRecord => userRecord.Uid);
839+
var expectedUids = new List<string>() { this.testUser1.Uid, this.testUser2.Uid, this.testUser3.Uid };
840+
Assert.True(expectedUids.All(expectedUid => uids.Contains(expectedUid)));
841+
Assert.Empty(getUsersResult.NotFound);
842+
}
843+
844+
[Fact]
845+
public async void IgnoresNonExistingUsers()
846+
{
847+
var doesntExistId = new UidIdentifier("uid_that_doesnt_exist");
848+
var getUsersResult = await this.auth.GetUsersAsync(new List<UserIdentifier>()
849+
{
850+
new UidIdentifier(this.testUser1.Uid),
851+
doesntExistId,
852+
new UidIdentifier(this.testUser3.Uid),
853+
});
854+
855+
var uids = getUsersResult.Users.Select(userRecord => userRecord.Uid);
856+
var expectedUids = new List<string>() { this.testUser1.Uid, this.testUser3.Uid };
857+
Assert.True(expectedUids.All(expectedUid => uids.Contains(expectedUid)));
858+
Assert.Equal(doesntExistId, getUsersResult.NotFound.Single());
859+
}
860+
861+
[Fact]
862+
public async void OnlyNonExistingUsers()
863+
{
864+
var doesntExistId = new UidIdentifier("uid_that_doesnt_exist");
865+
var getUsersResult = await this.auth.GetUsersAsync(new List<UserIdentifier>()
866+
{
867+
doesntExistId,
868+
});
869+
870+
Assert.Empty(getUsersResult.Users);
871+
Assert.Equal(doesntExistId, getUsersResult.NotFound.Single());
872+
}
873+
874+
[Fact]
875+
public async void DedupsDuplicateUsers()
876+
{
877+
var getUsersResult = await this.auth.GetUsersAsync(new List<UserIdentifier>()
878+
{
879+
new UidIdentifier(this.testUser1.Uid),
880+
new UidIdentifier(this.testUser1.Uid),
881+
});
882+
883+
var uids = getUsersResult.Users.Select(userRecord => userRecord.Uid);
884+
var expectedUids = new List<string>() { this.testUser3.Uid };
885+
Assert.Equal(this.testUser1.Uid, getUsersResult.Users.Single().Uid);
886+
Assert.Empty(getUsersResult.NotFound);
887+
}
888+
}
613889
}
614890

615891
/**
@@ -656,6 +932,24 @@ internal class SignInResponse
656932
public string IdToken { get; set; }
657933
}
658934

935+
internal class VerifyPasswordRequest
936+
{
937+
[Newtonsoft.Json.JsonProperty("email")]
938+
public string Email { get; set; }
939+
940+
[Newtonsoft.Json.JsonProperty("password")]
941+
public string Password { get; set; }
942+
943+
[Newtonsoft.Json.JsonProperty("returnSecureToken")]
944+
public bool ReturnSecureToken { get; set; }
945+
}
946+
947+
internal class VerifyPasswordResponse
948+
{
949+
[Newtonsoft.Json.JsonProperty("idToken")]
950+
public string IdToken { get; set; }
951+
}
952+
659953
internal class RandomUser
660954
{
661955
internal string Uid { get; private set; }

0 commit comments

Comments
 (0)