diff --git a/.travis.yml b/.travis.yml index 11a5af12..b1d02a82 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,8 @@ language: go go_import_path: firebase.google.com/go -script: go test -v -test.short ./... +before_install: + - go get github.com/golang/lint/golint +script: + - golint -set_exit_status $(go list ./...) + - go test -v -test.short ./... diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59c4edab..6aa95251 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -137,3 +137,18 @@ go test firebase.google.com/go/... ``` This will execute both unit and integration test suites. + +### Test Coverage + +Coverage can be measured per package by passing the `-cover` flag to the test invocation: + +```bash +go test -cover firebase.google.com/go/auth +``` + +To view the detailed coverage reports (per package): + +```bash +go test -cover -coverprofile=coverage.out firebase.google.com/go +go tool cover -html=coverage.out +``` diff --git a/README.md b/README.md index 06a6e7e5..441b5fad 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,9 @@ requests, code review feedback, and also pull requests. ## Documentation * [Setup Guide](https://firebase.google.com/docs/admin/setup/) +* [Authentication Guide](https://firebase.google.com/docs/auth/admin/) * [API Reference](https://godoc.org/firebase.google.com/go) +* [Release Notes](https://firebase.google.com/support/release-notes/admin/go) ## License and Terms diff --git a/auth/auth.go b/auth/auth.go index aa72c6c5..cc798182 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -26,12 +26,12 @@ import ( "firebase.google.com/go/internal" "golang.org/x/net/context" + "google.golang.org/api/identitytoolkit/v3" "google.golang.org/api/transport" ) const firebaseAudience = "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit" const googleCertURL = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com" -const idToolKitURL = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/" const issuerPrefix = "https://securetoken.google.com/" const tokenExpSeconds = 3600 @@ -63,10 +63,10 @@ type Token struct { // by Firebase backend services. type Client struct { hc *internal.HTTPClient + is *identitytoolkit.Service ks keySource projectID string snr signer - url string version string } @@ -117,32 +117,21 @@ func NewClient(ctx context.Context, c *internal.AuthConfig) (*Client, error) { return nil, err } + is, err := identitytoolkit.New(hc) + if err != nil { + return nil, err + } + return &Client{ hc: &internal.HTTPClient{Client: hc}, + is: is, ks: newHTTPKeySource(googleCertURL, hc), projectID: c.ProjectID, snr: snr, - url: idToolKitURL, version: "Go/Admin/" + c.Version, }, nil } -// Passes the request struct, returns a byte array of the json -func (c *Client) makeHTTPCall(ctx context.Context, serviceName string, payload interface{}, result interface{}) error { - versionHeader := internal.WithHeader("X-Client-Version", c.version) - request := &internal.Request{ - Method: "POST", - URL: c.url + serviceName, - Body: internal.NewJSONEntity(payload), - Opts: []internal.HTTPOption{versionHeader}, - } - resp, err := c.hc.Do(ctx, request) - if err != nil { - return err - } - return resp.Unmarshal(200, result) -} - // CustomToken creates a signed custom authentication token with the specified user ID. The resulting // JWT can be used in a Firebase client SDK to trigger an authentication flow. See // https://firebase.google.com/docs/auth/admin/create-custom-tokens#sign_in_using_custom_tokens_on_clients diff --git a/auth/crypto_test.go b/auth/crypto_test.go index 6c3306ef..ab5e642a 100644 --- a/auth/crypto_test.go +++ b/auth/crypto_test.go @@ -48,7 +48,7 @@ func newTestHTTPClient(data []byte) (*http.Client, *mockReadCloser) { Transport: &mockHTTPResponse{ Response: http.Response{ Status: "200 OK", - StatusCode: 200, + StatusCode: http.StatusOK, Header: http.Header{ "Cache-Control": {"public, max-age=100"}, }, diff --git a/auth/user_mgt.go b/auth/user_mgt.go index f1dea1f3..9d0098dc 100644 --- a/auth/user_mgt.go +++ b/auth/user_mgt.go @@ -17,17 +17,21 @@ package auth import ( "encoding/json" "fmt" + "net/http" + "reflect" "regexp" "strings" - "google.golang.org/api/iterator" - "golang.org/x/net/context" + "google.golang.org/api/identitytoolkit/v3" + "google.golang.org/api/iterator" ) const maxReturnedResults = 1000 const maxLenPayloadCC = 1000 +const defaultProviderID = "firebase" + var commonValidators = map[string]func(interface{}) error{ "displayName": validateDisplayName, "email": validateEmail, @@ -37,16 +41,27 @@ var commonValidators = map[string]func(interface{}) error{ "localId": validateUID, } +// Create a new interface +type identitytoolkitCall interface { + Header() http.Header +} + +// set header +func (c *Client) setHeader(ic identitytoolkitCall) { + ic.Header().Set("X-Client-Version", c.version) +} + // UserInfo is a collection of standard profile information for a user. type UserInfo struct { - DisplayName string `json:"displayName,omitempty"` - Email string `json:"email,omitempty"` - PhoneNumber string `json:"phoneNumber,omitempty"` - PhotoURL string `json:"photoUrl,omitempty"` - // ProviderID can be a short domain name (e.g. google.com), + DisplayName string + Email string + PhoneNumber string + PhotoURL string + // In the ProviderUserInfo[] ProviderID can be a short domain name (e.g. google.com), // or the identity of an OpenID identity provider. - ProviderID string `json:"providerId,omitempty"` - UID string `json:"localId,omitempty"` + // In UserRecord.UserInfo it will return the constant string "firebase". + ProviderID string + UID string } // UserMetadata contains additional metadata associated with a user account. @@ -182,9 +197,14 @@ func (c *Client) DeleteUser(ctx context.Context, uid string) error { if err := validateUID(uid); err != nil { return err } - var resp map[string]interface{} - deleteParams := map[string]interface{}{"localId": []string{uid}} - return c.makeHTTPCall(ctx, "deleteAccount", deleteParams, &resp) + request := &identitytoolkit.IdentitytoolkitRelyingpartyDeleteAccountRequest{ + LocalId: uid, + } + + call := c.is.Relyingparty.DeleteAccount(request) + c.setHeader(call) + _, err := call.Context(ctx).Do() + return err } // GetUser gets the user data corresponding to the specified user ID. @@ -192,7 +212,10 @@ func (c *Client) GetUser(ctx context.Context, uid string) (*UserRecord, error) { if err := validateUID(uid); err != nil { return nil, err } - return c.getUser(ctx, map[string]interface{}{"localId": []string{uid}}) + request := &identitytoolkit.IdentitytoolkitRelyingpartyGetAccountInfoRequest{ + LocalId: []string{uid}, + } + return c.getUser(ctx, request) } // GetUserByPhoneNumber gets the user data corresponding to the specified user phone number. @@ -200,7 +223,10 @@ func (c *Client) GetUserByPhoneNumber(ctx context.Context, phone string) (*UserR if err := validatePhone(phone); err != nil { return nil, err } - return c.getUser(ctx, map[string]interface{}{"phoneNumber": []string{phone}}) + request := &identitytoolkit.IdentitytoolkitRelyingpartyGetAccountInfoRequest{ + PhoneNumber: []string{phone}, + } + return c.getUser(ctx, request) } // GetUserByEmail gets the user data corresponding to the specified email. @@ -208,7 +234,10 @@ func (c *Client) GetUserByEmail(ctx context.Context, email string) (*UserRecord, if err := validateEmail(email); err != nil { return nil, err } - return c.getUser(ctx, map[string]interface{}{"email": []string{email}}) + request := &identitytoolkit.IdentitytoolkitRelyingpartyGetAccountInfoRequest{ + Email: []string{email}, + } + return c.getUser(ctx, request) } // Users returns an iterator over Users. @@ -230,25 +259,26 @@ func (c *Client) Users(ctx context.Context, nextPageToken string) *UserIterator } func (it *UserIterator) fetch(pageSize int, pageToken string) (string, error) { - params := map[string]interface{}{"maxResults": pageSize} - if pageToken != "" { - params["nextPageToken"] = pageToken + request := &identitytoolkit.IdentitytoolkitRelyingpartyDownloadAccountRequest{ + MaxResults: int64(pageSize), + NextPageToken: pageToken, } - - var lur listUsersResponse - err := it.client.makeHTTPCall(it.ctx, "downloadAccount", params, &lur) + call := it.client.is.Relyingparty.DownloadAccount(request) + it.client.setHeader(call) + resp, err := call.Context(it.ctx).Do() if err != nil { return "", err } - for _, u := range lur.Users { + + for _, u := range resp.Users { eu, err := makeExportedUser(u) if err != nil { return "", err } it.users = append(it.users, eu) } - it.pageInfo.Token = lur.NextPage - return lur.NextPage, nil + it.pageInfo.Token = resp.NextPageToken + return resp.NextPageToken, nil } // PageInfo supports pagination. See the google.golang.org/api/iterator package for details. @@ -386,10 +416,10 @@ func validatePhone(val interface{}) error { return nil } -func (u *UserToCreate) preparePayload() (map[string]interface{}, error) { +func (u *UserToCreate) preparePayload(user *identitytoolkit.IdentitytoolkitRelyingpartySignupNewUserRequest) error { params := map[string]interface{}{} if u.params == nil { - return params, nil + return nil } for k, v := range u.params { @@ -398,14 +428,28 @@ func (u *UserToCreate) preparePayload() (map[string]interface{}, error) { for key, validate := range commonValidators { if v, ok := params[key]; ok { if err := validate(v); err != nil { - return nil, err + return err } + reflect.ValueOf(user).Elem().FieldByName(strings.Title(key)).SetString(params[key].(string)) + } + } + if params["disabled"] != nil { + user.Disabled = params["disabled"].(bool) + if !user.Disabled { + user.ForceSendFields = append(user.ForceSendFields, "Disabled") + } + } + if params["emailVerified"] != nil { + user.EmailVerified = params["emailVerified"].(bool) + if !user.EmailVerified { + user.ForceSendFields = append(user.ForceSendFields, "EmailVerified") } } - return params, nil + + return nil } -func (u *UserToUpdate) preparePayload() (map[string]interface{}, error) { +func (u *UserToUpdate) preparePayload(user *identitytoolkit.IdentitytoolkitRelyingpartySetAccountInfoRequest) error { params := map[string]interface{}{} for k, v := range u.params { params[k] = v @@ -415,17 +459,41 @@ func (u *UserToUpdate) preparePayload() (map[string]interface{}, error) { processDeletion(params, "phoneNumber", "deleteProvider", "phone") if err := processClaims(params); err != nil { - return nil, err + return err + } + + if params["customAttributes"] != nil { + user.CustomAttributes = params["customAttributes"].(string) } for key, validate := range commonValidators { if v, ok := params[key]; ok { if err := validate(v); err != nil { - return nil, err + return err } + reflect.ValueOf(user).Elem().FieldByName(strings.Title(key)).SetString(params[key].(string)) + } + } + if params["disableUser"] != nil { + user.DisableUser = params["disableUser"].(bool) + if !user.DisableUser { + user.ForceSendFields = append(user.ForceSendFields, "DisableUser") } } - return params, nil + if params["emailVerified"] != nil { + user.EmailVerified = params["emailVerified"].(bool) + if !user.EmailVerified { + user.ForceSendFields = append(user.ForceSendFields, "EmailVerified") + } + } + if params["deleteAttribute"] != nil { + user.DeleteAttribute = params["deleteAttribute"].([]string) + } + if params["deleteProvider"] != nil { + user.DeleteProvider = params["deleteProvider"].([]string) + } + + return nil } // End of validators @@ -433,32 +501,32 @@ func (u *UserToUpdate) preparePayload() (map[string]interface{}, error) { // Response Types ------------------------------- type getUserResponse struct { - RequestType string `json:"kind,omitempty"` - Users []responseUserRecord `json:"users,omitempty"` + RequestType string + Users []responseUserRecord } type responseUserRecord struct { - UID string `json:"localId,omitempty"` - DisplayName string `json:"displayName,omitempty"` - Email string `json:"email,omitempty"` - PhoneNumber string `json:"phoneNumber,omitempty"` - PhotoURL string `json:"photoUrl,omitempty"` - CreationTimestamp int64 `json:"createdAt,string,omitempty"` - LastLogInTimestamp int64 `json:"lastLoginAt,string,omitempty"` - ProviderID string `json:"providerId,omitempty"` - CustomClaims string `json:"customAttributes,omitempty"` - Disabled bool `json:"disabled,omitempty"` - EmailVerified bool `json:"emailVerified,omitempty"` - ProviderUserInfo []*UserInfo `json:"providerUserInfo,omitempty"` - PasswordHash string `json:"passwordHash,omitempty"` - PasswordSalt string `json:"salt,omitempty"` - ValidSince int64 `json:"validSince,string,omitempty"` + UID string + DisplayName string + Email string + PhoneNumber string + PhotoURL string + CreationTimestamp int64 + LastLogInTimestamp int64 + ProviderID string + CustomClaims string + Disabled bool + EmailVerified bool + ProviderUserInfo []*UserInfo + PasswordHash string + PasswordSalt string + ValidSince int64 } type listUsersResponse struct { - RequestType string `json:"kind,omitempty"` - Users []responseUserRecord `json:"users,omitempty"` - NextPage string `json:"nextPageToken,omitempty"` + RequestType string + Users []responseUserRecord + NextPage string } // Helper functions for retrieval and HTTP calls. @@ -468,15 +536,20 @@ func (c *Client) createUser(ctx context.Context, user *UserToCreate) (string, er user = &UserToCreate{} } - payload, err := user.preparePayload() - if err != nil { + request := &identitytoolkit.IdentitytoolkitRelyingpartySignupNewUserRequest{} + + if err := user.preparePayload(request); err != nil { return "", err } - var rur responseUserRecord - if err := c.makeHTTPCall(ctx, "signupNewUser", payload, &rur); err != nil { + + call := c.is.Relyingparty.SignupNewUser(request) + c.setHeader(call) + resp, err := call.Context(ctx).Do() + if err != nil { return "", err } - return rur.UID, nil + + return resp.LocalId, nil } func (c *Client) updateUser(ctx context.Context, uid string, user *UserToUpdate) error { @@ -486,37 +559,44 @@ func (c *Client) updateUser(ctx context.Context, uid string, user *UserToUpdate) if user == nil || user.params == nil { return fmt.Errorf("update parameters must not be nil or empty") } - user.params["localId"] = uid - payload, err := user.preparePayload() - if err != nil { + request := &identitytoolkit.IdentitytoolkitRelyingpartySetAccountInfoRequest{ + LocalId: uid, + } + + if err := user.preparePayload(request); err != nil { return err } - var rur responseUserRecord - return c.makeHTTPCall(ctx, "setAccountInfo", payload, &rur) + call := c.is.Relyingparty.SetAccountInfo(request) + c.setHeader(call) + _, err := call.Context(ctx).Do() + + return err } -func (c *Client) getUser(ctx context.Context, params map[string]interface{}) (*UserRecord, error) { - var gur getUserResponse - err := c.makeHTTPCall(ctx, "getAccountInfo", params, &gur) +func (c *Client) getUser(ctx context.Context, request *identitytoolkit.IdentitytoolkitRelyingpartyGetAccountInfoRequest) (*UserRecord, error) { + call := c.is.Relyingparty.GetAccountInfo(request) + c.setHeader(call) + resp, err := call.Context(ctx).Do() if err != nil { return nil, err } - if len(gur.Users) == 0 { - return nil, fmt.Errorf("cannot find user from params: %v", params) + if len(resp.Users) == 0 { + return nil, fmt.Errorf("cannot find user from params: %v", request) } - eu, err := makeExportedUser(gur.Users[0]) + + eu, err := makeExportedUser(resp.Users[0]) if err != nil { return nil, err } return eu.UserRecord, nil } -func makeExportedUser(r responseUserRecord) (*ExportedUserRecord, error) { +func makeExportedUser(r *identitytoolkit.UserInfo) (*ExportedUserRecord, error) { var cc map[string]interface{} - if r.CustomClaims != "" { - err := json.Unmarshal([]byte(r.CustomClaims), &cc) + if r.CustomAttributes != "" { + err := json.Unmarshal([]byte(r.CustomAttributes), &cc) if err != nil { return nil, err } @@ -525,27 +605,40 @@ func makeExportedUser(r responseUserRecord) (*ExportedUserRecord, error) { } } + var providerUserInfo []*UserInfo + for _, u := range r.ProviderUserInfo { + info := &UserInfo{ + DisplayName: u.DisplayName, + Email: u.Email, + PhoneNumber: u.PhoneNumber, + PhotoURL: u.PhotoUrl, + ProviderID: u.ProviderId, + UID: u.RawId, + } + providerUserInfo = append(providerUserInfo, info) + } + resp := &ExportedUserRecord{ UserRecord: &UserRecord{ UserInfo: &UserInfo{ DisplayName: r.DisplayName, Email: r.Email, PhoneNumber: r.PhoneNumber, - PhotoURL: r.PhotoURL, - ProviderID: r.ProviderID, - UID: r.UID, + PhotoURL: r.PhotoUrl, + ProviderID: defaultProviderID, + UID: r.LocalId, }, CustomClaims: cc, Disabled: r.Disabled, EmailVerified: r.EmailVerified, - ProviderUserInfo: r.ProviderUserInfo, + ProviderUserInfo: providerUserInfo, UserMetadata: &UserMetadata{ - LastLogInTimestamp: r.LastLogInTimestamp, - CreationTimestamp: r.CreationTimestamp, + LastLogInTimestamp: r.LastLoginAt, + CreationTimestamp: r.CreatedAt, }, }, PasswordHash: r.PasswordHash, - PasswordSalt: r.PasswordSalt, + PasswordSalt: r.Salt, } return resp, nil } diff --git a/auth/user_mgt_test.go b/auth/user_mgt_test.go index edf5db0f..5c75fdf9 100644 --- a/auth/user_mgt_test.go +++ b/auth/user_mgt_test.go @@ -15,6 +15,7 @@ package auth import ( + "bytes" "encoding/json" "fmt" "io/ioutil" @@ -28,6 +29,7 @@ import ( "golang.org/x/net/context" "golang.org/x/oauth2" + "google.golang.org/api/identitytoolkit/v3" "google.golang.org/api/iterator" "google.golang.org/api/option" ) @@ -39,6 +41,7 @@ var testUser = &UserRecord{ PhoneNumber: "+1234567890", DisplayName: "Test User", PhotoURL: "http://www.example.com/testuser/photo.png", + ProviderID: defaultProviderID, }, Disabled: false, @@ -49,9 +52,11 @@ var testUser = &UserRecord{ DisplayName: "Test User", PhotoURL: "http://www.example.com/testuser/photo.png", Email: "testuser@example.com", + UID: "testuid", }, { ProviderID: "phone", PhoneNumber: "+1234567890", + UID: "testuid", }, }, UserMetadata: &UserMetadata{ @@ -314,10 +319,18 @@ func TestCreateUser(t *testing.T) { (&UserToCreate{}).Disabled(true), map[string]interface{}{"disabled": true}, }, + { + (&UserToCreate{}).Disabled(false), + map[string]interface{}{"disabled": false}, + }, { (&UserToCreate{}).EmailVerified(true), map[string]interface{}{"emailVerified": true}, }, + { + (&UserToCreate{}).EmailVerified(false), + map[string]interface{}{"emailVerified": false}, + }, { (&UserToCreate{}).PhotoURL("http://some.url"), map[string]interface{}{"photoUrl": "http://some.url"}, @@ -420,10 +433,18 @@ func TestUpdateUser(t *testing.T) { (&UserToUpdate{}).Disabled(true), map[string]interface{}{"disableUser": true}, }, + { + (&UserToUpdate{}).Disabled(false), + map[string]interface{}{"disableUser": false}, + }, { (&UserToUpdate{}).EmailVerified(true), map[string]interface{}{"emailVerified": true}, }, + { + (&UserToUpdate{}).EmailVerified(false), + map[string]interface{}{"emailVerified": false}, + }, { (&UserToUpdate{}).PhotoURL("http://some.url"), map[string]interface{}{"photoUrl": "http://some.url"}, @@ -576,32 +597,35 @@ func TestInvalidDeleteUser(t *testing.T) { } func TestMakeExportedUser(t *testing.T) { - rur := responseUserRecord{ - UID: "testuser", - Email: "testuser@example.com", - PhoneNumber: "+1234567890", - EmailVerified: true, - DisplayName: "Test User", - ProviderUserInfo: []*UserInfo{ + + rur := &identitytoolkit.UserInfo{ + LocalId: "testuser", + Email: "testuser@example.com", + PhoneNumber: "+1234567890", + EmailVerified: true, + DisplayName: "Test User", + Salt: "salt", + PhotoUrl: "http://www.example.com/testuser/photo.png", + PasswordHash: "passwordhash", + ValidSince: 1494364393, + Disabled: false, + CreatedAt: 1234567890, + LastLoginAt: 1233211232, + CustomAttributes: `{"admin": true, "package": "gold"}`, + ProviderUserInfo: []*identitytoolkit.UserInfoProviderUserInfo{ { - ProviderID: "password", + ProviderId: "password", DisplayName: "Test User", - PhotoURL: "http://www.example.com/testuser/photo.png", + PhotoUrl: "http://www.example.com/testuser/photo.png", Email: "testuser@example.com", + RawId: "testuid", }, { - ProviderID: "phone", + ProviderId: "phone", PhoneNumber: "+1234567890", + RawId: "testuid", }}, - PhotoURL: "http://www.example.com/testuser/photo.png", - PasswordHash: "passwordhash", - PasswordSalt: "salt", - - ValidSince: 1494364393, - Disabled: false, - CreationTimestamp: 1234567890, - LastLogInTimestamp: 1233211232, - CustomClaims: `{"admin": true, "package": "gold"}`, } + want := &ExportedUserRecord{ UserRecord: testUser, PasswordHash: "passwordhash", @@ -626,14 +650,14 @@ func TestMakeExportedUser(t *testing.T) { func TestHTTPError(t *testing.T) { s := echoServer([]byte(`{"error":"test"}`), t) defer s.Close() - s.Status = 500 + s.Status = http.StatusInternalServerError u, err := s.Client.GetUser(context.Background(), "some uid") if u != nil || err == nil { t.Fatalf("GetUser() = (%v, %v); want = (nil, error)", u, err) } - want := `http error status: 500; reason: {"error":"test"}` + want := `googleapi: got HTTP response code 500 with body: {"error":"test"}` if err.Error() != want { t.Errorf("GetUser() = %v; want = %q", err, want) } @@ -682,7 +706,7 @@ func echoServer(resp interface{}, t *testing.T) *mockAuthServer { if err != nil { t.Fatal(err) } - s.Rbody = reqBody + s.Rbody = bytes.TrimSpace(reqBody) s.Req = append(s.Req, r) gh := r.Header.Get("Authorization") @@ -719,7 +743,7 @@ func echoServer(resp interface{}, t *testing.T) *mockAuthServer { if err != nil { t.Fatal(err) } - authClient.url = s.Srv.URL + "/" + authClient.is.BasePath = s.Srv.URL + "/" s.Client = authClient return &s } diff --git a/firebase.go b/firebase.go index c0ed3569..f9b9db49 100644 --- a/firebase.go +++ b/firebase.go @@ -18,7 +18,10 @@ package firebase import ( + "encoding/json" "errors" + "io/ioutil" + "os" "cloud.google.com/go/firestore" @@ -27,8 +30,6 @@ import ( "firebase.google.com/go/internal" "firebase.google.com/go/storage" - "os" - "golang.org/x/net/context" "golang.org/x/oauth2/google" "google.golang.org/api/option" @@ -45,7 +46,10 @@ var firebaseScopes = []string{ } // Version of the Firebase Go Admin SDK. -const Version = "2.3.0" +const Version = "2.4.0" + +// firebaseEnvName is the name of the environment variable with the Config. +const firebaseEnvName = "FIREBASE_CONFIG" // An App holds configuration and state common to all Firebase services that are exposed from the SDK. type App struct { @@ -57,8 +61,8 @@ type App struct { // Config represents the configuration used to initialize an App. type Config struct { - ProjectID string - StorageBucket string + ProjectID string `json:"projectId"` + StorageBucket string `json:"storageBucket"` } // Auth returns an instance of auth.Client. @@ -107,14 +111,14 @@ func (a *App) InstanceID(ctx context.Context) (*iid.Client, error) { func NewApp(ctx context.Context, config *Config, opts ...option.ClientOption) (*App, error) { o := []option.ClientOption{option.WithScopes(firebaseScopes...)} o = append(o, opts...) - creds, err := transport.Creds(ctx, o...) if err != nil { return nil, err } - if config == nil { - config = &Config{} + if config, err = getConfigDefaults(); err != nil { + return nil, err + } } var pid string @@ -133,3 +137,24 @@ func NewApp(ctx context.Context, config *Config, opts ...option.ClientOption) (* opts: o, }, nil } + +// getConfigDefaults reads the default config file, defined by the FIREBASE_CONFIG +// env variable, used only when options are nil. +func getConfigDefaults() (*Config, error) { + fbc := &Config{} + confFileName := os.Getenv(firebaseEnvName) + if confFileName == "" { + return fbc, nil + } + var dat []byte + if confFileName[0] == byte('{') { + dat = []byte(confFileName) + } else { + var err error + if dat, err = ioutil.ReadFile(confFileName); err != nil { + return nil, err + } + } + err := json.Unmarshal(dat, fbc) + return fbc, err +} diff --git a/firebase_test.go b/firebase_test.go index 9e2388ce..686d6af5 100644 --- a/firebase_test.go +++ b/firebase_test.go @@ -16,6 +16,7 @@ package firebase import ( "io/ioutil" + "log" "net/http" "net/http/httptest" "os" @@ -35,6 +36,17 @@ import ( "google.golang.org/api/option" ) +const credEnvVar = "GOOGLE_APPLICATION_CREDENTIALS" + +func TestMain(m *testing.M) { + // This isolates the tests from a possiblity that the default config env + // variable is set to a valid file containing the wanted default config, + // but we the test is not expecting it. + configOld := overwriteEnv(firebaseEnvName, "") + defer reinstateEnv(firebaseEnvName, configOld) + os.Exit(m.Run()) +} + func TestServiceAcctFile(t *testing.T) { app, err := NewApp(context.Background(), nil, option.WithCredentialsFile("testdata/service_account.json")) if err != nil { @@ -85,8 +97,8 @@ func TestClientOptions(t *testing.T) { if err != nil { t.Fatal(err) } - if resp.StatusCode != 200 { - t.Errorf("Status: %d; want: 200", resp.StatusCode) + if resp.StatusCode != http.StatusOK { + t.Errorf("Status: %d; want: %d", resp.StatusCode, http.StatusOK) } if bearer != "Bearer mock-token" { t.Errorf("Bearer token: %q; want: %q", bearer, "Bearer mock-token") @@ -151,13 +163,12 @@ func TestRefreshTokenWithEnvVar(t *testing.T) { } func TestAppDefault(t *testing.T) { - varName := "GOOGLE_APPLICATION_CREDENTIALS" - current := os.Getenv(varName) + current := os.Getenv(credEnvVar) - if err := os.Setenv(varName, "testdata/service_account.json"); err != nil { + if err := os.Setenv(credEnvVar, "testdata/service_account.json"); err != nil { t.Fatal(err) } - defer os.Setenv(varName, current) + defer os.Setenv(credEnvVar, current) app, err := NewApp(context.Background(), nil) if err != nil { @@ -175,13 +186,12 @@ func TestAppDefault(t *testing.T) { } func TestAppDefaultWithInvalidFile(t *testing.T) { - varName := "GOOGLE_APPLICATION_CREDENTIALS" - current := os.Getenv(varName) + current := os.Getenv(credEnvVar) - if err := os.Setenv(varName, "testdata/non_existing.json"); err != nil { + if err := os.Setenv(credEnvVar, "testdata/non_existing.json"); err != nil { t.Fatal(err) } - defer os.Setenv(varName, current) + defer os.Setenv(credEnvVar, current) app, err := NewApp(context.Background(), nil) if app != nil || err == nil { @@ -318,8 +328,8 @@ func TestCustomTokenSource(t *testing.T) { if err != nil { t.Fatal(err) } - if resp.StatusCode != 200 { - t.Errorf("Status: %d; want: 200", resp.StatusCode) + if resp.StatusCode != http.StatusOK { + t.Errorf("Status: %d; want: %d", resp.StatusCode, http.StatusOK) } if bearer != "Bearer "+ts.AccessToken { t.Errorf("Bearer token: %q; want: %q", bearer, "Bearer "+ts.AccessToken) @@ -337,6 +347,139 @@ func TestVersion(t *testing.T) { } } } +func TestAutoInit(t *testing.T) { + tests := []struct { + name string + optionsConfig string + initOptions *Config + wantOptions *Config + }{ + { + "No environment variable, no explicit options", + "", + nil, + &Config{ProjectID: "mock-project-id"}, // from default creds here and below. + }, { + "Environment variable set to file, no explicit options", + "testdata/firebase_config.json", + nil, + &Config{ + ProjectID: "hipster-chat-mock", + StorageBucket: "hipster-chat.appspot.mock", + }, + }, { + "Environment variable set to string, no explicit options", + `{ + "projectId": "hipster-chat-mock", + "storageBucket": "hipster-chat.appspot.mock" + }`, + nil, + &Config{ + ProjectID: "hipster-chat-mock", + StorageBucket: "hipster-chat.appspot.mock", + }, + }, { + "Environment variable set to file with some values missing, no explicit options", + "testdata/firebase_config_partial.json", + nil, + &Config{ProjectID: "hipster-chat-mock"}, + }, { + "Environment variable set to string with some values missing, no explicit options", + `{"projectId": "hipster-chat-mock"}`, + nil, + &Config{ProjectID: "hipster-chat-mock"}, + }, { + "Environment variable set to file which is ignored as some explicit options are passed", + "testdata/firebase_config_partial.json", + &Config{StorageBucket: "sb1-mock"}, + &Config{ + ProjectID: "mock-project-id", + StorageBucket: "sb1-mock", + }, + }, { + "Environment variable set to string which is ignored as some explicit options are passed", + `{"projectId": "hipster-chat-mock"}`, + &Config{StorageBucket: "sb1-mock"}, + &Config{ + ProjectID: "mock-project-id", + StorageBucket: "sb1-mock", + }, + }, { + "Environment variable set to file which is ignored as options are explicitly empty", + "testdata/firebase_config_partial.json", + &Config{}, + &Config{ProjectID: "mock-project-id"}, + }, { + "Environment variable set to file with an unknown key which is ignored, no explicit options", + "testdata/firebase_config_invalid_key.json", + nil, + &Config{ + ProjectID: "mock-project-id", // from default creds + StorageBucket: "hipster-chat.appspot.mock", + }, + }, { + "Environment variable set to string with an unknown key which is ignored, no explicit options", + `{ + "obviously_bad_key": "hipster-chat-mock", + "storageBucket": "hipster-chat.appspot.mock" + }`, + nil, + &Config{ + ProjectID: "mock-project-id", + StorageBucket: "hipster-chat.appspot.mock", + }, + }, + } + + credOld := overwriteEnv(credEnvVar, "testdata/service_account.json") + defer reinstateEnv(credEnvVar, credOld) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + overwriteEnv(firebaseEnvName, test.optionsConfig) + app, err := NewApp(context.Background(), test.initOptions) + if err != nil { + t.Error(err) + } else { + compareConfig(app, test.wantOptions, t) + } + }) + } +} + +func TestAutoInitInvalidFiles(t *testing.T) { + tests := []struct { + name string + filename string + wantError string + }{ + { + "nonexistant file", + "testdata/no_such_file.json", + "open testdata/no_such_file.json: no such file or directory", + }, { + "invalid JSON", + "testdata/firebase_config_invalid.json", + "invalid character 'b' looking for beginning of value", + }, { + "empty file", + "testdata/firebase_config_empty.json", + "unexpected end of JSON input", + }, + } + credOld := overwriteEnv(credEnvVar, "testdata/service_account.json") + defer reinstateEnv(credEnvVar, credOld) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + overwriteEnv(firebaseEnvName, test.filename) + _, err := NewApp(context.Background(), nil) + if err == nil || err.Error() != test.wantError { + t.Errorf("got error = %s; want = %s", err, test.wantError) + } + }) + } +} type testTokenSource struct { AccessToken string @@ -350,6 +493,15 @@ func (t *testTokenSource) Token() (*oauth2.Token, error) { }, nil } +func compareConfig(got *App, want *Config, t *testing.T) { + if got.projectID != want.ProjectID { + t.Errorf("app.projectID = %q; want = %q", got.projectID, want.ProjectID) + } + if got.storageBucket != want.StorageBucket { + t.Errorf("app.storageBucket = %q; want = %q", got.storageBucket, want.StorageBucket) + } +} + // mockServiceAcct generates a service account configuration with the provided URL as the // token_url value. func mockServiceAcct(tokenURL string) ([]byte, error) { @@ -379,3 +531,25 @@ func initMockTokenServer() *httptest.Server { }`)) })) } + +// overwriteEnv overwrites env variables, used in testsing. +func overwriteEnv(varName, newVal string) string { + oldVal := os.Getenv(varName) + if newVal == "" { + if err := os.Unsetenv(varName); err != nil { + log.Fatal(err) + } + } else if err := os.Setenv(varName, newVal); err != nil { + log.Fatal(err) + } + return oldVal +} + +// reinstateEnv restores the enviornment variable, will usually be used deferred with overwriteEnv. +func reinstateEnv(varName, oldVal string) { + if len(varName) > 0 { + os.Setenv(varName, oldVal) + } else { + os.Unsetenv(varName) + } +} diff --git a/iid/iid.go b/iid/iid.go index d7798e1a..980a7bed 100644 --- a/iid/iid.go +++ b/iid/iid.go @@ -30,14 +30,14 @@ import ( const iidEndpoint = "https://console.firebase.google.com/v1" var errorCodes = map[int]string{ - 400: "malformed instance id argument", - 401: "request not authorized", - 403: "project does not match instance ID or the client does not have sufficient privileges", - 404: "failed to find the instance id", - 409: "already deleted", - 429: "request throttled out by the backend server", - 500: "internal server error", - 503: "backend servers are over capacity", + http.StatusBadRequest: "malformed instance id argument", + http.StatusUnauthorized: "request not authorized", + http.StatusForbidden: "project does not match instance ID or the client does not have sufficient privileges", + http.StatusNotFound: "failed to find the instance id", + http.StatusConflict: "already deleted", + http.StatusTooManyRequests: "request throttled out by the backend server", + http.StatusInternalServerError: "internal server error", + http.StatusServiceUnavailable: "backend servers are over capacity", } // Client is the interface for the Firebase Instance ID service. @@ -79,7 +79,7 @@ func (c *Client) DeleteInstanceID(ctx context.Context, iid string) error { } url := fmt.Sprintf("%s/project/%s/instanceId/%s", c.endpoint, c.project, iid) - resp, err := c.client.Do(ctx, &internal.Request{Method: "DELETE", URL: url}) + resp, err := c.client.Do(ctx, &internal.Request{Method: http.MethodDelete, URL: url}) if err != nil { return err } diff --git a/iid/iid_test.go b/iid/iid_test.go index 9a7c6d15..6d154650 100644 --- a/iid/iid_test.go +++ b/iid/iid_test.go @@ -75,8 +75,8 @@ func TestDeleteInstanceID(t *testing.T) { if tr == nil { t.Fatalf("Request = nil; want non-nil") } - if tr.Method != "DELETE" { - t.Errorf("Method = %q; want = %q", tr.Method, "DELETE") + if tr.Method != http.MethodDelete { + t.Errorf("Method = %q; want = %q", tr.Method, http.MethodDelete) } if tr.URL.Path != "/project/test-project/instanceId/test-iid" { t.Errorf("Path = %q; want = %q", tr.URL.Path, "/project/test-project/instanceId/test-iid") @@ -87,7 +87,7 @@ func TestDeleteInstanceID(t *testing.T) { } func TestDeleteInstanceIDError(t *testing.T) { - status := 200 + status := http.StatusOK var tr *http.Request ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tr = r @@ -119,8 +119,8 @@ func TestDeleteInstanceIDError(t *testing.T) { if tr == nil { t.Fatalf("Request = nil; want non-nil") } - if tr.Method != "DELETE" { - t.Errorf("Method = %q; want = %q", tr.Method, "DELETE") + if tr.Method != http.MethodDelete { + t.Errorf("Method = %q; want = %q", tr.Method, http.MethodDelete) } if tr.URL.Path != "/project/test-project/instanceId/test-iid" { t.Errorf("Path = %q; want = %q", tr.URL.Path, "/project/test-project/instanceId/test-iid") @@ -162,8 +162,8 @@ func TestDeleteInstanceIDUnexpectedError(t *testing.T) { if tr == nil { t.Fatalf("Request = nil; want non-nil") } - if tr.Method != "DELETE" { - t.Errorf("Method = %q; want = %q", tr.Method, "DELETE") + if tr.Method != http.MethodDelete { + t.Errorf("Method = %q; want = %q", tr.Method, http.MethodDelete) } if tr.URL.Path != "/project/test-project/instanceId/test-iid" { t.Errorf("Path = %q; want = %q", tr.URL.Path, "/project/test-project/instanceId/test-iid") diff --git a/integration/auth/auth_test.go b/integration/auth/auth_test.go index 63e26295..ee0dc0b3 100644 --- a/integration/auth/auth_test.go +++ b/integration/auth/auth_test.go @@ -139,7 +139,7 @@ func postRequest(url string, req []byte) ([]byte, error) { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected http status code: %d", resp.StatusCode) } return ioutil.ReadAll(resp.Body) diff --git a/integration/auth/user_mgt_test.go b/integration/auth/user_mgt_test.go index d626c4f7..8227599f 100644 --- a/integration/auth/user_mgt_test.go +++ b/integration/auth/user_mgt_test.go @@ -212,7 +212,10 @@ func testUpdateUser(t *testing.T) { } want := &auth.UserRecord{ - UserInfo: &auth.UserInfo{UID: testFixtures.sampleUserBlank.UID}, + UserInfo: &auth.UserInfo{ + UID: testFixtures.sampleUserBlank.UID, + ProviderID: "firebase", + }, UserMetadata: &auth.UserMetadata{ CreationTimestamp: testFixtures.sampleUserBlank.UserMetadata.CreationTimestamp, }, @@ -241,6 +244,7 @@ func testUpdateUser(t *testing.T) { DisplayName: "name", PhoneNumber: "+12345678901", PhotoURL: "http://photo.png", + ProviderID: "firebase", Email: "abc@ab.ab", }, UserMetadata: &auth.UserMetadata{ @@ -257,10 +261,12 @@ func testUpdateUser(t *testing.T) { Email: "abc@ab.ab", PhotoURL: "http://photo.png", ProviderID: "password", + UID: "abc@ab.ab", } phoneUI := &auth.UserInfo{ PhoneNumber: "+12345678901", ProviderID: "phone", + UID: "+12345678901", } var compareWith *auth.UserInfo @@ -277,7 +283,7 @@ func testUpdateUser(t *testing.T) { } } - // compare provider info seperatley since the order of the providers isn't guaranteed. + // compare provider info separately since the order of the providers isn't guaranteed. testProviderInfo(u.ProviderUserInfo, t) // now compare the rest of the record, without the ProviderInfo diff --git a/integration/storage/storage_test.go b/integration/storage/storage_test.go index 078e61c6..5efe92d2 100644 --- a/integration/storage/storage_test.go +++ b/integration/storage/storage_test.go @@ -116,8 +116,5 @@ func verifyBucket(bucket *gcs.BucketHandle) error { } // Delete the object - if err := o.Delete(ctx); err != nil { - return err - } - return nil + return o.Delete(ctx) } diff --git a/internal/http_client_test.go b/internal/http_client_test.go index 67b1c35b..bdac7474 100644 --- a/internal/http_client_test.go +++ b/internal/http_client_test.go @@ -33,25 +33,25 @@ var cases = []struct { }{ { req: &Request{ - Method: "GET", + Method: http.MethodGet, }, - method: "GET", + method: http.MethodGet, }, { req: &Request{ - Method: "GET", + Method: http.MethodGet, Opts: []HTTPOption{ WithHeader("Test-Header", "value1"), WithQueryParam("testParam", "value2"), }, }, - method: "GET", + method: http.MethodGet, headers: map[string]string{"Test-Header": "value1"}, query: map[string]string{"testParam": "value2"}, }, { req: &Request{ - Method: "POST", + Method: http.MethodPost, Body: NewJSONEntity(map[string]string{"foo": "bar"}), Opts: []HTTPOption{ WithHeader("Test-Header", "value1"), @@ -59,35 +59,35 @@ var cases = []struct { WithQueryParam("testParam2", "value3"), }, }, - method: "POST", + method: http.MethodPost, body: "{\"foo\":\"bar\"}", headers: map[string]string{"Test-Header": "value1"}, query: map[string]string{"testParam1": "value2", "testParam2": "value3"}, }, { req: &Request{ - Method: "POST", + Method: http.MethodPost, Body: NewJSONEntity("body"), Opts: []HTTPOption{ WithHeader("Test-Header", "value1"), WithQueryParams(map[string]string{"testParam1": "value2", "testParam2": "value3"}), }, }, - method: "POST", + method: http.MethodPost, body: "\"body\"", headers: map[string]string{"Test-Header": "value1"}, query: map[string]string{"testParam1": "value2", "testParam2": "value3"}, }, { req: &Request{ - Method: "PUT", + Method: http.MethodPut, Body: NewJSONEntity(nil), Opts: []HTTPOption{ WithHeader("Test-Header", "value1"), WithQueryParams(map[string]string{"testParam1": "value2", "testParam2": "value3"}), }, }, - method: "PUT", + method: http.MethodPut, body: "null", headers: map[string]string{"Test-Header": "value1"}, query: map[string]string{"testParam1": "value2", "testParam2": "value3"}, @@ -184,7 +184,7 @@ func TestContext(t *testing.T) { client := &HTTPClient{Client: http.DefaultClient} ctx, cancel := context.WithCancel(context.Background()) resp, err := client.Do(ctx, &Request{ - Method: "GET", + Method: http.MethodGet, URL: server.URL, }) if err != nil { @@ -196,7 +196,7 @@ func TestContext(t *testing.T) { cancel() resp, err = client.Do(ctx, &Request{ - Method: "GET", + Method: http.MethodGet, URL: server.URL, }) if resp != nil || err == nil { @@ -234,7 +234,7 @@ func TestErrorParser(t *testing.T) { Client: http.DefaultClient, ErrParser: ep, } - req := &Request{Method: "GET", URL: server.URL} + req := &Request{Method: http.MethodGet, URL: server.URL} resp, err := client.Do(context.Background(), req) if err != nil { t.Fatal(err) @@ -255,7 +255,7 @@ func TestErrorParser(t *testing.T) { func TestInvalidURL(t *testing.T) { req := &Request{ - Method: "GET", + Method: http.MethodGet, URL: "http://localhost:250/mock.url", } client := &HTTPClient{Client: http.DefaultClient} @@ -281,7 +281,7 @@ func TestUnmarshalError(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - req := &Request{Method: "GET", URL: server.URL} + req := &Request{Method: http.MethodGet, URL: server.URL} client := &HTTPClient{Client: http.DefaultClient} resp, err := client.Do(context.Background(), req) if err != nil { diff --git a/testdata/firebase_config.json b/testdata/firebase_config.json new file mode 100644 index 00000000..d249fe76 --- /dev/null +++ b/testdata/firebase_config.json @@ -0,0 +1,4 @@ +{ + "projectId": "hipster-chat-mock", + "storageBucket": "hipster-chat.appspot.mock" +} diff --git a/testdata/firebase_config_empty.json b/testdata/firebase_config_empty.json new file mode 100644 index 00000000..e69de29b diff --git a/testdata/firebase_config_invalid.json b/testdata/firebase_config_invalid.json new file mode 100644 index 00000000..485aba4f --- /dev/null +++ b/testdata/firebase_config_invalid.json @@ -0,0 +1 @@ +baaad diff --git a/testdata/firebase_config_invalid_key.json b/testdata/firebase_config_invalid_key.json new file mode 100644 index 00000000..8fad82c8 --- /dev/null +++ b/testdata/firebase_config_invalid_key.json @@ -0,0 +1,4 @@ +{ + "project1d_bad_key": "hipster-chat-mock", + "storageBucket": "hipster-chat.appspot.mock" +} diff --git a/testdata/firebase_config_partial.json b/testdata/firebase_config_partial.json new file mode 100644 index 00000000..1775043e --- /dev/null +++ b/testdata/firebase_config_partial.json @@ -0,0 +1,3 @@ +{ + "projectId": "hipster-chat-mock" +} diff --git a/testdata/get_user.json b/testdata/get_user.json index dc992a64..a56ef9f3 100644 --- a/testdata/get_user.json +++ b/testdata/get_user.json @@ -1,31 +1,36 @@ { - "kind" : "identitytoolkit#GetAccountInfoResponse", - "users" : [ { - "localId" : "testuser", - "email" : "testuser@example.com", - "phoneNumber" : "+1234567890", - "emailVerified" : true, - "displayName" : "Test User", - "providerUserInfo" : [ { - "providerId" : "password", - "displayName" : "Test User", - "photoUrl" : "http://www.example.com/testuser/photo.png", - "federatedId" : "testuser@example.com", - "email" : "testuser@example.com", - "rawId" : "testuser@example.com" - }, { - "providerId" : "phone", - "phoneNumber" : "+1234567890", - "rawId" : "+1234567890" - } ], - "photoUrl" : "http://www.example.com/testuser/photo.png", - "passwordHash" : "passwordhash", - "salt" : "salt===", - "passwordUpdatedAt" : 1.494364393E+12, - "validSince" : "1494364393", - "disabled" : false, - "createdAt" : "1234567890", - "lastLoginAt" :"1233211232", - "customAttributes" : "{\"admin\": true, \"package\": \"gold\"}" - } ] + "kind": "identitytoolkit#GetAccountInfoResponse", + "users": [ + { + "localId": "testuser", + "email": "testuser@example.com", + "phoneNumber": "+1234567890", + "emailVerified": true, + "displayName": "Test User", + "providerUserInfo": [ + { + "providerId": "password", + "displayName": "Test User", + "photoUrl": "http://www.example.com/testuser/photo.png", + "federatedId": "testuser@example.com", + "email": "testuser@example.com", + "rawId": "testuid" + }, + { + "providerId": "phone", + "phoneNumber": "+1234567890", + "rawId": "testuid" + } + ], + "photoUrl": "http://www.example.com/testuser/photo.png", + "passwordHash": "passwordhash", + "salt": "salt===", + "passwordUpdatedAt": 1.494364393E+12, + "validSince": "1494364393", + "disabled": false, + "createdAt": "1234567890", + "lastLoginAt": "1233211232", + "customAttributes": "{\"admin\": true, \"package\": \"gold\"}" + } + ] } diff --git a/testdata/list_users.json b/testdata/list_users.json index e4749ab3..21d152fc 100644 --- a/testdata/list_users.json +++ b/testdata/list_users.json @@ -2,88 +2,97 @@ "kind": "identitytoolkit#DownloadAccountResponse", "users": [ { - "localId" : "testuser", - "email" : "testuser@example.com", - "phoneNumber" : "+1234567890", - "emailVerified" : true, - "displayName" : "Test User", - "providerUserInfo" : [ { - "providerId" : "password", - "displayName" : "Test User", - "photoUrl" : "http://www.example.com/testuser/photo.png", - "federatedId" : "testuser@example.com", - "email" : "testuser@example.com", - "rawId" : "testuser@example.com" - }, { - "providerId" : "phone", - "phoneNumber" : "+1234567890", - "rawId" : "+1234567890" - } ], - "photoUrl" : "http://www.example.com/testuser/photo.png", - "passwordHash" : "passwordhash1", - "salt" : "salt1", - "passwordUpdatedAt" : 1.494364393E+12, - "validSince" : "1494364393", - "disabled" : false, - "createdAt" : "1234567890", - "lastLoginAt" :"1233211232", - "customAttributes" : "{\"admin\": true, \"package\": \"gold\"}" + "localId": "testuser", + "email": "testuser@example.com", + "phoneNumber": "+1234567890", + "emailVerified": true, + "displayName": "Test User", + "providerUserInfo": [ + { + "providerId": "password", + "displayName": "Test User", + "photoUrl": "http://www.example.com/testuser/photo.png", + "federatedId": "testuser@example.com", + "email": "testuser@example.com", + "rawId": "testuid" + }, + { + "providerId": "phone", + "phoneNumber": "+1234567890", + "rawId": "testuid" + } + ], + "photoUrl": "http://www.example.com/testuser/photo.png", + "passwordHash": "passwordhash1", + "salt": "salt1", + "passwordUpdatedAt": 1.494364393E+12, + "validSince": "1494364393", + "disabled": false, + "createdAt": "1234567890", + "lastLoginAt": "1233211232", + "customAttributes": "{\"admin\": true, \"package\": \"gold\"}" }, { - "localId" : "testuser", - "email" : "testuser@example.com", - "phoneNumber" : "+1234567890", - "emailVerified" : true, - "displayName" : "Test User", - "providerUserInfo" : [ { - "providerId" : "password", - "displayName" : "Test User", - "photoUrl" : "http://www.example.com/testuser/photo.png", - "federatedId" : "testuser@example.com", - "email" : "testuser@example.com", - "rawId" : "testuser@example.com" - }, { - "providerId" : "phone", - "phoneNumber" : "+1234567890", - "rawId" : "+1234567890" - } ], - "photoUrl" : "http://www.example.com/testuser/photo.png", - "passwordHash" : "passwordhash2", - "salt" : "salt2", - "passwordUpdatedAt" : 1.494364393E+12, - "validSince" : "1494364393", - "disabled" : false, - "createdAt" : "1234567890", - "lastLoginAt" :"1233211232", - "customAttributes" : "{\"admin\": true, \"package\": \"gold\"}" + "localId": "testuser", + "email": "testuser@example.com", + "phoneNumber": "+1234567890", + "emailVerified": true, + "displayName": "Test User", + "providerUserInfo": [ + { + "providerId": "password", + "displayName": "Test User", + "photoUrl": "http://www.example.com/testuser/photo.png", + "federatedId": "testuser@example.com", + "email": "testuser@example.com", + "rawId": "testuid" + }, + { + "providerId": "phone", + "phoneNumber": "+1234567890", + "rawId": "testuid" + } + ], + "photoUrl": "http://www.example.com/testuser/photo.png", + "passwordHash": "passwordhash2", + "salt": "salt2", + "passwordUpdatedAt": 1.494364393E+12, + "validSince": "1494364393", + "disabled": false, + "createdAt": "1234567890", + "lastLoginAt": "1233211232", + "customAttributes": "{\"admin\": true, \"package\": \"gold\"}" }, { - "localId" : "testuser", - "email" : "testuser@example.com", - "phoneNumber" : "+1234567890", - "emailVerified" : true, - "displayName" : "Test User", - "providerUserInfo" : [ { - "providerId" : "password", - "displayName" : "Test User", - "photoUrl" : "http://www.example.com/testuser/photo.png", - "federatedId" : "testuser@example.com", - "email" : "testuser@example.com", - "rawId" : "testuser@example.com" - }, { - "providerId" : "phone", - "phoneNumber" : "+1234567890", - "rawId" : "+1234567890" - } ], - "photoUrl" : "http://www.example.com/testuser/photo.png", - "passwordHash" : "passwordhash3", - "salt" : "salt3", - "passwordUpdatedAt" : 1.494364393E+12, - "validSince" : "1494364393", - "disabled" : false, - "createdAt" : "1234567890", - "lastLoginAt" :"1233211232", - "customAttributes" : "{\"admin\": true, \"package\": \"gold\"}" + "localId": "testuser", + "email": "testuser@example.com", + "phoneNumber": "+1234567890", + "emailVerified": true, + "displayName": "Test User", + "providerUserInfo": [ + { + "providerId": "password", + "displayName": "Test User", + "photoUrl": "http://www.example.com/testuser/photo.png", + "federatedId": "testuser@example.com", + "email": "testuser@example.com", + "rawId": "testuid" + }, + { + "providerId": "phone", + "phoneNumber": "+1234567890", + "rawId": "testuid" + } + ], + "photoUrl": "http://www.example.com/testuser/photo.png", + "passwordHash": "passwordhash3", + "salt": "salt3", + "passwordUpdatedAt": 1.494364393E+12, + "validSince": "1494364393", + "disabled": false, + "createdAt": "1234567890", + "lastLoginAt": "1233211232", + "customAttributes": "{\"admin\": true, \"package\": \"gold\"}" } ], "nextPageToken": ""