Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
322 changes: 313 additions & 9 deletions auth/user_mgt.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ const (
maxLenPayloadCC = 1000
defaultProviderID = "firebase"
idToolkitV1Endpoint = "https://identitytoolkit.googleapis.com/v1"

// Maximum allowed number of users to batch get at one time.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest: "Maximum number of users allowed to batch get at a time."

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I've also applied this to the maxDeleteAccountsBatchSize comment immediately below.

maxGetAccountsBatchSize = 100

// Maximum allowed numberof users to batch delete at one time.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add space between "number" and "of"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

maxDeleteAccountsBatchSize = 1000
)

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

// UserRecord contains metadata associated with a Firebase user account.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserRecord? Not familiar with the style guide for GO, please add back ticks if necessary.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't seem to be a thing; or at least, they don't do it here: https://blog.golang.org/godoc-documenting-go-code or here: https://golang.org/doc/effective_go.html#commentary

Nonetheless, we do seem to use backticks occasionally... though seemingly only when referring to some other function (and I suspect inconsistently at that.)

I've left this alone.

Expand Down Expand Up @@ -331,6 +340,7 @@ const (
unauthorizedContinueURI = "unauthorized-continue-uri"
unknown = "unknown-error"
userNotFound = "user-not-found"
maximumUserCountExceeded = "maximum-user-count-exceeded"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be needed. We don't use error codes for developer errors.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

)

// IsConfigurationNotFound checks if the given error was due to a non-existing IdP configuration.
Expand Down Expand Up @@ -491,6 +501,15 @@ func validatePhone(phone string) error {
return nil
}

func validateProvider(providerID string, providerUID string) error {
if providerID == "" {
return fmt.Errorf("providerID must be a non-empty string")
} else if providerUID == "" {
return fmt.Errorf("providerUID must be a non-empty string")
}
return nil
}

// End of validators

// GetUser gets the user data corresponding to the specified user ID.
Expand Down Expand Up @@ -545,12 +564,13 @@ func (q *userQuery) build() map[string]interface{} {
}
}

type getAccountInfoResponse struct {
Users []*userQueryResponse `json:"users"`
}

func (c *baseClient) getUser(ctx context.Context, query *userQuery) (*UserRecord, error) {
var parsed struct {
Users []*userQueryResponse `json:"users"`
}
_, err := c.post(ctx, "/accounts:lookup", query.build(), &parsed)
if err != nil {
var parsed getAccountInfoResponse
if _, err := c.post(ctx, "/accounts:lookup", query.build(), &parsed); err != nil {
return nil, err
}

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

// A UserIdentifier identifies a user to be looked up.
type UserIdentifier interface {
matches(ur *UserRecord) bool
populate(req *getAccountInfoRequest)
}

// A UIDIdentifier is used for looking up an account by uid.
//
// See GetUsers function.
type UIDIdentifier struct {
UID string
}

func (id UIDIdentifier) matches(ur *UserRecord) bool {
return id.UID == ur.UID
}

func (id UIDIdentifier) populate(req *getAccountInfoRequest) {
req.LocalID = append(req.LocalID, id.UID)
}

// An EmailIdentifier is used for looking up an account by email.
//
// See GetUsers function.
type EmailIdentifier struct {
Email string
}

func (id EmailIdentifier) matches(ur *UserRecord) bool {
return id.Email == ur.Email
}

func (id EmailIdentifier) populate(req *getAccountInfoRequest) {
req.Email = append(req.Email, id.Email)
}

// A PhoneIdentifier is used for looking up an account by phone number.
//
// See GetUsers function.
type PhoneIdentifier struct {
PhoneNumber string
}

func (id PhoneIdentifier) matches(ur *UserRecord) bool {
return id.PhoneNumber == ur.PhoneNumber
}

func (id PhoneIdentifier) populate(req *getAccountInfoRequest) {
req.PhoneNumber = append(req.PhoneNumber, id.PhoneNumber)
}

// A ProviderIdentifier is used for looking up an account by federated provider.
//
// See GetUsers function.
type ProviderIdentifier struct {
ProviderID string
ProviderUID string
}

func (id ProviderIdentifier) matches(ur *UserRecord) bool {
for _, userInfo := range ur.ProviderUserInfo {
if id.ProviderID == userInfo.ProviderID && id.ProviderUID == userInfo.UID {
return true
}
}
return false
}

func (id ProviderIdentifier) populate(req *getAccountInfoRequest) {
req.FederatedUserID = append(
req.FederatedUserID,
federatedUserIdentifier{ProviderID: id.ProviderID, RawID: id.ProviderUID})
}

// A GetUsersResult represents the result of the GetUsers() API.
type GetUsersResult struct {
// Set of UserRecords, corresponding to the set of users that were requested.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest omitting this comma.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

// Only users that were found are listed here. The result set is unordered.
Users []*UserRecord

// Set of UserIdentifiers that were requested, but not found.
NotFound []UserIdentifier
}

type federatedUserIdentifier struct {
ProviderID string `json:"providerId,omitempty"`
RawID string `json:"rawId,omitempty"`
}

type getAccountInfoRequest struct {
LocalID []string `json:"localId,omitempty"`
Email []string `json:"email,omitempty"`
PhoneNumber []string `json:"phoneNumber,omitempty"`
FederatedUserID []federatedUserIdentifier `json:"federatedUserId,omitempty"`
}

func (req *getAccountInfoRequest) validate() error {
for i := range req.LocalID {
if err := validateUID(req.LocalID[i]); err != nil {
return err
}
}

for i := range req.Email {
if err := validateEmail(req.Email[i]); err != nil {
return err
}
}

for i := range req.PhoneNumber {
if err := validatePhone(req.PhoneNumber[i]); err != nil {
return err
}
}

for i := range req.FederatedUserID {
id := &req.FederatedUserID[i]
if err := validateProvider(id.ProviderID, id.RawID); err != nil {
return err
}
}

return nil
}

func isUserFound(id UserIdentifier, urs [](*UserRecord)) bool {
for i := range urs {
if id.matches(urs[i]) {
return true
}
}
return false
}

// GetUsers returns the user data corresponding to the specified identifiers.
//
// There are no ordering guarantees; in particular, the nth entry in the users
// result list is not guaranteed to correspond to the nth entry in the input
// parameters list.
//
// Only a maximum of 100 identifiers may be supplied. If more than 100

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest "A maximum..."

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

// identifiers are supplied, this method will immediately return an error.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest "method returns an error."

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

//
// Returns the corresponding user records. An error is returned instead if any
// of the identifiers are invalid or if more than 100 identifiers are
// specified.
func (c *baseClient) GetUsers(
ctx context.Context, identifiers []UserIdentifier,
) (*GetUsersResult, error) {
if len(identifiers) == 0 {
return &GetUsersResult{[](*UserRecord){}, [](UserIdentifier){}}, nil
} else if len(identifiers) > maxGetAccountsBatchSize {
return nil, fmt.Errorf(
"`identifiers` parameter must have <= %d entries", maxGetAccountsBatchSize)
}

var request getAccountInfoRequest
for i := range identifiers {
identifiers[i].populate(&request)
}

if err := request.validate(); err != nil {
return nil, err
}

var parsed getAccountInfoResponse
if _, err := c.post(ctx, "/accounts:lookup", request, &parsed); err != nil {
return nil, err
}

var userRecords [](*UserRecord)
for _, user := range parsed.Users {
userRecord, err := user.makeUserRecord()
if err != nil {
return nil, err
}
userRecords = append(userRecords, userRecord)
}

var notFound []UserIdentifier
for i := range identifiers {
if !isUserFound(identifiers[i], userRecords) {
notFound = append(notFound, identifiers[i])
}
}

return &GetUsersResult{userRecords, notFound}, nil
}

type userQueryResponse struct {
UID string `json:"localId,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Expand All @@ -569,6 +778,7 @@ type userQueryResponse struct {
PhotoURL string `json:"photoUrl,omitempty"`
CreationTimestamp int64 `json:"createdAt,string,omitempty"`
LastLogInTimestamp int64 `json:"lastLoginAt,string,omitempty"`
LastRefreshAt string `json:"lastRefreshAt,omitempty"`
ProviderID string `json:"providerId,omitempty"`
CustomAttributes string `json:"customAttributes,omitempty"`
Disabled bool `json:"disabled,omitempty"`
Expand All @@ -592,8 +802,7 @@ func (r *userQueryResponse) makeUserRecord() (*UserRecord, error) {
func (r *userQueryResponse) makeExportedUserRecord() (*ExportedUserRecord, error) {
var customClaims map[string]interface{}
if r.CustomAttributes != "" {
err := json.Unmarshal([]byte(r.CustomAttributes), &customClaims)
if err != nil {
if err := json.Unmarshal([]byte(r.CustomAttributes), &customClaims); err != nil {
return nil, err
}
if len(customClaims) == 0 {
Expand All @@ -609,6 +818,15 @@ func (r *userQueryResponse) makeExportedUserRecord() (*ExportedUserRecord, error
hash = ""
}

var lastRefreshTimestamp int64
if r.LastRefreshAt != "" {
t, err := time.Parse(time.RFC3339, r.LastRefreshAt)
if err != nil {
return nil, err
}
lastRefreshTimestamp = t.Unix()
}

return &ExportedUserRecord{
UserRecord: &UserRecord{
UserInfo: &UserInfo{
Expand All @@ -626,8 +844,9 @@ func (r *userQueryResponse) makeExportedUserRecord() (*ExportedUserRecord, error
TenantID: r.TenantID,
TokensValidAfterMillis: r.ValidSinceSeconds * 1000,
UserMetadata: &UserMetadata{
LastLogInTimestamp: r.LastLogInTimestamp,
CreationTimestamp: r.CreationTimestamp,
LastLogInTimestamp: r.LastLogInTimestamp,
CreationTimestamp: r.CreationTimestamp,
LastRefreshTimestamp: lastRefreshTimestamp,
},
},
PasswordHash: hash,
Expand Down Expand Up @@ -728,6 +947,91 @@ func (c *baseClient) DeleteUser(ctx context.Context, uid string) error {
return err
}

// A DeleteUsersResult represents the result of the DeleteUsers() call.
type DeleteUsersResult struct {
// The number of users that were deleted successfully (possibly zero). Users
// that did not exist prior to calling DeleteUsers() will be considered to be

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest "are considered"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

// successfully deleted.
SuccessCount int

// The number of users that failed to be deleted (possibly zero).
FailureCount int

// A list of describing the errors that were encountered

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete "of" if this list actually describes the errors. Otherwise, just "A list of errors encountered..."

Suggest checking whether this was copied into similar comment in other SDKs, and we just missed it :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh; the type somehow got dropped. (Which is mildly redundant info in most ports since you can get it from the type signature; python being the obvious exception.) The other ports seem ok.

I've added the type to match the other ports. Alternatively, we could just remove the type everywhere (except python).

// during the deletion. Length of this list is equal to the value of
// FailureCount.
Errors []*DeleteUsersErrorInfo
}

// DeleteUsersErrorInfo represents an error encountered while deleting a user
// account.
//
// The Index field corresponds to the index of the failed user in the uids
// array that was passed to DeleteUsers().
type DeleteUsersErrorInfo struct {
Index int `json:"index,omitEmpty"`
Reason string `json:"message,omitEmpty"`
}

// DeleteUsers deletes the users specified by the given identifiers.
//
// Deleting a non-existing user won't generate an error. (i.e. this method is
// idempotent.) Non-existing users will be considered to be successfully

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest "are considered"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

// deleted, and will therefore be counted in the DeleteUsersResult.SuccessCount

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest "are counted"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

// value.
//
// Only a maximum of 1000 identifiers may be supplied. If more than 1000

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest "A maximum..."

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

// identifiers are supplied, this method will immediately return an error.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest "method returns an error"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

//
// This API is currently rate limited at the server to 1 QPS. If you exceed
// this, you may get a quota exceeded error. Therefore, if you want to delete
// more than 1000 users, you may need to add a delay to ensure you don't go
// over this limit.
//
// Returns the total number of successful/failed deletions, as well as the
// array of errors that correspond to the failed deletions. An error is
// returned if any of the identifiers are invalid or if more than 1000
// identifiers are specified.
func (c *baseClient) DeleteUsers(ctx context.Context, uids []string) (*DeleteUsersResult, error) {
if len(uids) == 0 {
return &DeleteUsersResult{}, nil
} else if len(uids) > maxDeleteAccountsBatchSize {
return nil, fmt.Errorf(
"`uids` parameter must have <= %d entries", maxDeleteAccountsBatchSize)
}

var payload struct {
LocalIds []string `json:"localIds"`
Force bool `json:"force"`
}
payload.Force = true

for i := range uids {
if err := validateUID(uids[i]); err != nil {
return nil, err
}

payload.LocalIds = append(payload.LocalIds, uids[i])
}

type batchDeleteAccountsResponse struct {
Errors []*DeleteUsersErrorInfo `json:"errors"`
}

resp := batchDeleteAccountsResponse{}
if _, err := c.post(ctx, "/accounts:batchDelete", payload, &resp); err != nil {
return nil, err
}

result := DeleteUsersResult{
FailureCount: len(resp.Errors),
SuccessCount: len(uids) - len(resp.Errors),
Errors: resp.Errors,
}

return &result, nil
}

// SessionCookie creates a new Firebase session cookie from the given ID token and expiry
// duration. The returned JWT can be set as a server-side session cookie with a custom cookie
// policy. Expiry duration must be at least 5 minutes but may not exceed 14 days.
Expand Down
Loading