Skip to content

Commit 96a7111

Browse files
authored
Add bulk get/delete methods (#325)
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. Resolves #138
1 parent dc5cf02 commit 96a7111

File tree

4 files changed

+949
-11
lines changed

4 files changed

+949
-11
lines changed

auth/user_mgt.go

Lines changed: 312 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ const (
3434
maxLenPayloadCC = 1000
3535
defaultProviderID = "firebase"
3636
idToolkitV1Endpoint = "https://identitytoolkit.googleapis.com/v1"
37+
38+
// Maximum number of users allowed to batch get at a time.
39+
maxGetAccountsBatchSize = 100
40+
41+
// Maximum number of users allowed to batch delete at a time.
42+
maxDeleteAccountsBatchSize = 1000
3743
)
3844

3945
// 'REDACTED', encoded as a base64 string.
@@ -57,6 +63,9 @@ type UserInfo struct {
5763
type UserMetadata struct {
5864
CreationTimestamp int64
5965
LastLogInTimestamp int64
66+
// The time at which the user was last active (ID token refreshed), or 0 if
67+
// the user was never active.
68+
LastRefreshTimestamp int64
6069
}
6170

6271
// UserRecord contains metadata associated with a Firebase user account.
@@ -491,6 +500,15 @@ func validatePhone(phone string) error {
491500
return nil
492501
}
493502

503+
func validateProvider(providerID string, providerUID string) error {
504+
if providerID == "" {
505+
return fmt.Errorf("providerID must be a non-empty string")
506+
} else if providerUID == "" {
507+
return fmt.Errorf("providerUID must be a non-empty string")
508+
}
509+
return nil
510+
}
511+
494512
// End of validators
495513

496514
// GetUser gets the user data corresponding to the specified user ID.
@@ -545,12 +563,13 @@ func (q *userQuery) build() map[string]interface{} {
545563
}
546564
}
547565

566+
type getAccountInfoResponse struct {
567+
Users []*userQueryResponse `json:"users"`
568+
}
569+
548570
func (c *baseClient) getUser(ctx context.Context, query *userQuery) (*UserRecord, error) {
549-
var parsed struct {
550-
Users []*userQueryResponse `json:"users"`
551-
}
552-
_, err := c.post(ctx, "/accounts:lookup", query.build(), &parsed)
553-
if err != nil {
571+
var parsed getAccountInfoResponse
572+
if _, err := c.post(ctx, "/accounts:lookup", query.build(), &parsed); err != nil {
554573
return nil, err
555574
}
556575

@@ -561,6 +580,195 @@ func (c *baseClient) getUser(ctx context.Context, query *userQuery) (*UserRecord
561580
return parsed.Users[0].makeUserRecord()
562581
}
563582

583+
// A UserIdentifier identifies a user to be looked up.
584+
type UserIdentifier interface {
585+
matches(ur *UserRecord) bool
586+
populate(req *getAccountInfoRequest)
587+
}
588+
589+
// A UIDIdentifier is used for looking up an account by uid.
590+
//
591+
// See GetUsers function.
592+
type UIDIdentifier struct {
593+
UID string
594+
}
595+
596+
func (id UIDIdentifier) matches(ur *UserRecord) bool {
597+
return id.UID == ur.UID
598+
}
599+
600+
func (id UIDIdentifier) populate(req *getAccountInfoRequest) {
601+
req.LocalID = append(req.LocalID, id.UID)
602+
}
603+
604+
// An EmailIdentifier is used for looking up an account by email.
605+
//
606+
// See GetUsers function.
607+
type EmailIdentifier struct {
608+
Email string
609+
}
610+
611+
func (id EmailIdentifier) matches(ur *UserRecord) bool {
612+
return id.Email == ur.Email
613+
}
614+
615+
func (id EmailIdentifier) populate(req *getAccountInfoRequest) {
616+
req.Email = append(req.Email, id.Email)
617+
}
618+
619+
// A PhoneIdentifier is used for looking up an account by phone number.
620+
//
621+
// See GetUsers function.
622+
type PhoneIdentifier struct {
623+
PhoneNumber string
624+
}
625+
626+
func (id PhoneIdentifier) matches(ur *UserRecord) bool {
627+
return id.PhoneNumber == ur.PhoneNumber
628+
}
629+
630+
func (id PhoneIdentifier) populate(req *getAccountInfoRequest) {
631+
req.PhoneNumber = append(req.PhoneNumber, id.PhoneNumber)
632+
}
633+
634+
// A ProviderIdentifier is used for looking up an account by federated provider.
635+
//
636+
// See GetUsers function.
637+
type ProviderIdentifier struct {
638+
ProviderID string
639+
ProviderUID string
640+
}
641+
642+
func (id ProviderIdentifier) matches(ur *UserRecord) bool {
643+
for _, userInfo := range ur.ProviderUserInfo {
644+
if id.ProviderID == userInfo.ProviderID && id.ProviderUID == userInfo.UID {
645+
return true
646+
}
647+
}
648+
return false
649+
}
650+
651+
func (id ProviderIdentifier) populate(req *getAccountInfoRequest) {
652+
req.FederatedUserID = append(
653+
req.FederatedUserID,
654+
federatedUserIdentifier{ProviderID: id.ProviderID, RawID: id.ProviderUID})
655+
}
656+
657+
// A GetUsersResult represents the result of the GetUsers() API.
658+
type GetUsersResult struct {
659+
// Set of UserRecords corresponding to the set of users that were requested.
660+
// Only users that were found are listed here. The result set is unordered.
661+
Users []*UserRecord
662+
663+
// Set of UserIdentifiers that were requested, but not found.
664+
NotFound []UserIdentifier
665+
}
666+
667+
type federatedUserIdentifier struct {
668+
ProviderID string `json:"providerId,omitempty"`
669+
RawID string `json:"rawId,omitempty"`
670+
}
671+
672+
type getAccountInfoRequest struct {
673+
LocalID []string `json:"localId,omitempty"`
674+
Email []string `json:"email,omitempty"`
675+
PhoneNumber []string `json:"phoneNumber,omitempty"`
676+
FederatedUserID []federatedUserIdentifier `json:"federatedUserId,omitempty"`
677+
}
678+
679+
func (req *getAccountInfoRequest) validate() error {
680+
for i := range req.LocalID {
681+
if err := validateUID(req.LocalID[i]); err != nil {
682+
return err
683+
}
684+
}
685+
686+
for i := range req.Email {
687+
if err := validateEmail(req.Email[i]); err != nil {
688+
return err
689+
}
690+
}
691+
692+
for i := range req.PhoneNumber {
693+
if err := validatePhone(req.PhoneNumber[i]); err != nil {
694+
return err
695+
}
696+
}
697+
698+
for i := range req.FederatedUserID {
699+
id := &req.FederatedUserID[i]
700+
if err := validateProvider(id.ProviderID, id.RawID); err != nil {
701+
return err
702+
}
703+
}
704+
705+
return nil
706+
}
707+
708+
func isUserFound(id UserIdentifier, urs [](*UserRecord)) bool {
709+
for i := range urs {
710+
if id.matches(urs[i]) {
711+
return true
712+
}
713+
}
714+
return false
715+
}
716+
717+
// GetUsers returns the user data corresponding to the specified identifiers.
718+
//
719+
// There are no ordering guarantees; in particular, the nth entry in the users
720+
// result list is not guaranteed to correspond to the nth entry in the input
721+
// parameters list.
722+
//
723+
// A maximum of 100 identifiers may be supplied. If more than 100
724+
// identifiers are supplied, this method returns an error.
725+
//
726+
// Returns the corresponding user records. An error is returned instead if any
727+
// of the identifiers are invalid or if more than 100 identifiers are
728+
// specified.
729+
func (c *baseClient) GetUsers(
730+
ctx context.Context, identifiers []UserIdentifier,
731+
) (*GetUsersResult, error) {
732+
if len(identifiers) == 0 {
733+
return &GetUsersResult{[](*UserRecord){}, [](UserIdentifier){}}, nil
734+
} else if len(identifiers) > maxGetAccountsBatchSize {
735+
return nil, fmt.Errorf(
736+
"`identifiers` parameter must have <= %d entries", maxGetAccountsBatchSize)
737+
}
738+
739+
var request getAccountInfoRequest
740+
for i := range identifiers {
741+
identifiers[i].populate(&request)
742+
}
743+
744+
if err := request.validate(); err != nil {
745+
return nil, err
746+
}
747+
748+
var parsed getAccountInfoResponse
749+
if _, err := c.post(ctx, "/accounts:lookup", request, &parsed); err != nil {
750+
return nil, err
751+
}
752+
753+
var userRecords [](*UserRecord)
754+
for _, user := range parsed.Users {
755+
userRecord, err := user.makeUserRecord()
756+
if err != nil {
757+
return nil, err
758+
}
759+
userRecords = append(userRecords, userRecord)
760+
}
761+
762+
var notFound []UserIdentifier
763+
for i := range identifiers {
764+
if !isUserFound(identifiers[i], userRecords) {
765+
notFound = append(notFound, identifiers[i])
766+
}
767+
}
768+
769+
return &GetUsersResult{userRecords, notFound}, nil
770+
}
771+
564772
type userQueryResponse struct {
565773
UID string `json:"localId,omitempty"`
566774
DisplayName string `json:"displayName,omitempty"`
@@ -569,6 +777,7 @@ type userQueryResponse struct {
569777
PhotoURL string `json:"photoUrl,omitempty"`
570778
CreationTimestamp int64 `json:"createdAt,string,omitempty"`
571779
LastLogInTimestamp int64 `json:"lastLoginAt,string,omitempty"`
780+
LastRefreshAt string `json:"lastRefreshAt,omitempty"`
572781
ProviderID string `json:"providerId,omitempty"`
573782
CustomAttributes string `json:"customAttributes,omitempty"`
574783
Disabled bool `json:"disabled,omitempty"`
@@ -592,8 +801,7 @@ func (r *userQueryResponse) makeUserRecord() (*UserRecord, error) {
592801
func (r *userQueryResponse) makeExportedUserRecord() (*ExportedUserRecord, error) {
593802
var customClaims map[string]interface{}
594803
if r.CustomAttributes != "" {
595-
err := json.Unmarshal([]byte(r.CustomAttributes), &customClaims)
596-
if err != nil {
804+
if err := json.Unmarshal([]byte(r.CustomAttributes), &customClaims); err != nil {
597805
return nil, err
598806
}
599807
if len(customClaims) == 0 {
@@ -609,6 +817,15 @@ func (r *userQueryResponse) makeExportedUserRecord() (*ExportedUserRecord, error
609817
hash = ""
610818
}
611819

820+
var lastRefreshTimestamp int64
821+
if r.LastRefreshAt != "" {
822+
t, err := time.Parse(time.RFC3339, r.LastRefreshAt)
823+
if err != nil {
824+
return nil, err
825+
}
826+
lastRefreshTimestamp = t.Unix() * 1000
827+
}
828+
612829
return &ExportedUserRecord{
613830
UserRecord: &UserRecord{
614831
UserInfo: &UserInfo{
@@ -626,8 +843,9 @@ func (r *userQueryResponse) makeExportedUserRecord() (*ExportedUserRecord, error
626843
TenantID: r.TenantID,
627844
TokensValidAfterMillis: r.ValidSinceSeconds * 1000,
628845
UserMetadata: &UserMetadata{
629-
LastLogInTimestamp: r.LastLogInTimestamp,
630-
CreationTimestamp: r.CreationTimestamp,
846+
LastLogInTimestamp: r.LastLogInTimestamp,
847+
CreationTimestamp: r.CreationTimestamp,
848+
LastRefreshTimestamp: lastRefreshTimestamp,
631849
},
632850
},
633851
PasswordHash: hash,
@@ -728,6 +946,91 @@ func (c *baseClient) DeleteUser(ctx context.Context, uid string) error {
728946
return err
729947
}
730948

949+
// A DeleteUsersResult represents the result of the DeleteUsers() call.
950+
type DeleteUsersResult struct {
951+
// The number of users that were deleted successfully (possibly zero). Users
952+
// that did not exist prior to calling DeleteUsers() are considered to be
953+
// successfully deleted.
954+
SuccessCount int
955+
956+
// The number of users that failed to be deleted (possibly zero).
957+
FailureCount int
958+
959+
// A list of DeleteUsersErrorInfo instances describing the errors that were
960+
// encountered during the deletion. Length of this list is equal to the value
961+
// of FailureCount.
962+
Errors []*DeleteUsersErrorInfo
963+
}
964+
965+
// DeleteUsersErrorInfo represents an error encountered while deleting a user
966+
// account.
967+
//
968+
// The Index field corresponds to the index of the failed user in the uids
969+
// array that was passed to DeleteUsers().
970+
type DeleteUsersErrorInfo struct {
971+
Index int `json:"index,omitEmpty"`
972+
Reason string `json:"message,omitEmpty"`
973+
}
974+
975+
// DeleteUsers deletes the users specified by the given identifiers.
976+
//
977+
// Deleting a non-existing user won't generate an error. (i.e. this method is
978+
// idempotent.) Non-existing users are considered to be successfully
979+
// deleted, and are therefore counted in the DeleteUsersResult.SuccessCount
980+
// value.
981+
//
982+
// A maximum of 1000 identifiers may be supplied. If more than 1000
983+
// identifiers are supplied, this method returns an error.
984+
//
985+
// This API is currently rate limited at the server to 1 QPS. If you exceed
986+
// this, you may get a quota exceeded error. Therefore, if you want to delete
987+
// more than 1000 users, you may need to add a delay to ensure you don't go
988+
// over this limit.
989+
//
990+
// Returns the total number of successful/failed deletions, as well as the
991+
// array of errors that correspond to the failed deletions. An error is
992+
// returned if any of the identifiers are invalid or if more than 1000
993+
// identifiers are specified.
994+
func (c *baseClient) DeleteUsers(ctx context.Context, uids []string) (*DeleteUsersResult, error) {
995+
if len(uids) == 0 {
996+
return &DeleteUsersResult{}, nil
997+
} else if len(uids) > maxDeleteAccountsBatchSize {
998+
return nil, fmt.Errorf(
999+
"`uids` parameter must have <= %d entries", maxDeleteAccountsBatchSize)
1000+
}
1001+
1002+
var payload struct {
1003+
LocalIds []string `json:"localIds"`
1004+
Force bool `json:"force"`
1005+
}
1006+
payload.Force = true
1007+
1008+
for i := range uids {
1009+
if err := validateUID(uids[i]); err != nil {
1010+
return nil, err
1011+
}
1012+
1013+
payload.LocalIds = append(payload.LocalIds, uids[i])
1014+
}
1015+
1016+
type batchDeleteAccountsResponse struct {
1017+
Errors []*DeleteUsersErrorInfo `json:"errors"`
1018+
}
1019+
1020+
resp := batchDeleteAccountsResponse{}
1021+
if _, err := c.post(ctx, "/accounts:batchDelete", payload, &resp); err != nil {
1022+
return nil, err
1023+
}
1024+
1025+
result := DeleteUsersResult{
1026+
FailureCount: len(resp.Errors),
1027+
SuccessCount: len(uids) - len(resp.Errors),
1028+
Errors: resp.Errors,
1029+
}
1030+
1031+
return &result, nil
1032+
}
1033+
7311034
// SessionCookie creates a new Firebase session cookie from the given ID token and expiry
7321035
// duration. The returned JWT can be set as a server-side session cookie with a custom cookie
7331036
// policy. Expiry duration must be at least 5 minutes but may not exceed 14 days.

0 commit comments

Comments
 (0)