diff --git a/CHANGELOG.md b/CHANGELOG.md index 754e2f73..51a8c4af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,14 @@ # Unreleased + +### Token revocation +- [added] A New ['VerifyIDTokenAndCheckRevoked(ctx, token)'](https://godoc.org/firebase.google.com/go/auth#Client.VerifyIDToken) + method has been added to check for revoked ID tokens. +- [added] A new method ['RevokeRefreshTokens(uid)'](https://godoc.org/firebase.google.com/go/auth#Client.RevokeRefreshTokens) + has been added to invalidate all refresh tokens issued to a user. +- [added] A new property + `TokensValidAfterMillis` has been added to the ['UserRecord'](https://godoc.org/firebase.google.com/go/auth#UserRecord). + This property stores the time of the revocation truncated to 1 second accuracy. + - Import context from golang.org/x/net/ for 1.6 compatibility # v2.4.0 diff --git a/auth/auth.go b/auth/auth.go index cc798182..f6605c7b 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -177,6 +177,18 @@ func (c *Client) CustomTokenWithClaims(uid string, devClaims map[string]interfac return encodeToken(c.snr, defaultHeader(), payload) } +// RevokeRefreshTokens revokes all refresh tokens issued to a user. +// +// RevokeRefreshTokens updates the user's TokensValidAfterMillis to the current UTC second. +// It is important that the server on which this is called has its clock set correctly and synchronized. +// +// While this revokes all sessions for a specified user and disables any new ID tokens for existing sessions +// from getting minted, existing ID tokens may remain active until their natural expiration (one hour). +// To verify that ID tokens are revoked, use `verifyIdTokenAndCheckRevoked(ctx, idToken)`. +func (c *Client) RevokeRefreshTokens(ctx context.Context, uid string) error { + return c.updateUser(ctx, uid, (&UserToUpdate{}).revokeRefreshTokens()) +} + // VerifyIDToken verifies the signature and payload of the provided ID token. // // VerifyIDToken accepts a signed JWT token string, and verifies that it is current, issued for the @@ -184,6 +196,7 @@ func (c *Client) CustomTokenWithClaims(uid string, devClaims map[string]interfac // a Token containing the decoded claims in the input JWT. See // https://firebase.google.com/docs/auth/admin/verify-id-tokens#retrieve_id_tokens_on_clients for // more details on how to obtain an ID token in a client app. +// This does not check whether or not the token has been revoked. See `VerifyIDTokenAndCheckRevoked` below. func (c *Client) VerifyIDToken(idToken string) (*Token, error) { if c.projectID == "" { return nil, errors.New("project id not available") @@ -237,6 +250,27 @@ func (c *Client) VerifyIDToken(idToken string) (*Token, error) { return p, nil } +// VerifyIDTokenAndCheckRevoked verifies the provided ID token and checks it has not been revoked. +// +// VerifyIDTokenAndCheckRevoked verifies the signature and payload of the provided ID token and +// checks that it wasn't revoked. Uses VerifyIDToken() internally to verify the ID token JWT. +func (c *Client) VerifyIDTokenAndCheckRevoked(ctx context.Context, idToken string) (*Token, error) { + p, err := c.VerifyIDToken(idToken) + if err != nil { + return nil, err + } + + user, err := c.GetUser(ctx, p.UID) + if err != nil { + return nil, err + } + + if p.IssuedAt*1000 < user.TokensValidAfterMillis { + return nil, fmt.Errorf("ID token has been revoked") + } + return p, nil +} + func parseKey(key string) (*rsa.PrivateKey, error) { block, _ := pem.Decode([]byte(key)) if block == nil { diff --git a/auth/auth_test.go b/auth/auth_test.go index 79e957d8..690b5d6f 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -37,10 +37,10 @@ import ( ) var client *Client +var ctx context.Context var testIDToken string var testGetUserResponse []byte var testListUsersResponse []byte - var defaultTestOpts = []option.ClientOption{ option.WithCredentialsFile("../testdata/service_account.json"), } @@ -49,7 +49,6 @@ func TestMain(m *testing.M) { var ( err error ks keySource - ctx context.Context creds *google.DefaultCredentials opts []option.ClientOption ) @@ -193,6 +192,52 @@ func TestCustomTokenInvalidCredential(t *testing.T) { } } +func TestVerifyIDTokenAndCheckRevokedValid(t *testing.T) { + s := echoServer(testGetUserResponse, t) + defer s.Close() + + ft, err := s.Client.VerifyIDTokenAndCheckRevoked(ctx, testIDToken) + if err != nil { + t.Error(err) + } + if ft.Claims["admin"] != true { + t.Errorf("Claims['admin'] = %v; want = true", ft.Claims["admin"]) + } + if ft.UID != ft.Subject { + t.Errorf("UID = %q; Sub = %q; want UID = Sub", ft.UID, ft.Subject) + } +} + +func TestVerifyIDTokenAndCheckRevokedDoNotCheck(t *testing.T) { + s := echoServer(testGetUserResponse, t) + defer s.Close() + tok := getIDToken(mockIDTokenPayload{"uid": "uid", "iat": 1970}) // old token + + ft, err := s.Client.VerifyIDToken(tok) + if err != nil { + t.Fatal(err) + } + if ft.Claims["admin"] != true { + t.Errorf("Claims['admin'] = %v; want = true", ft.Claims["admin"]) + } + if ft.UID != ft.Subject { + t.Errorf("UID = %q; Sub = %q; want UID = Sub", ft.UID, ft.Subject) + } +} + +func TestVerifyIDTokenAndCheckRevokedInvalidated(t *testing.T) { + s := echoServer(testGetUserResponse, t) + defer s.Close() + tok := getIDToken(mockIDTokenPayload{"uid": "uid", "iat": 1970}) // old token + + p, err := s.Client.VerifyIDTokenAndCheckRevoked(ctx, tok) + we := "ID token has been revoked" + if p != nil || err == nil || err.Error() != we { + t.Errorf("VerifyIDTokenAndCheckRevoked(ctx, token) =(%v, %v); want = (%v, %v)", + p, err, nil, we) + } +} + func TestVerifyIDToken(t *testing.T) { ft, err := client.VerifyIDToken(testIDToken) if err != nil { diff --git a/auth/user_mgt.go b/auth/user_mgt.go index eb92d9a5..423ceece 100644 --- a/auth/user_mgt.go +++ b/auth/user_mgt.go @@ -21,6 +21,7 @@ import ( "reflect" "regexp" "strings" + "time" "golang.org/x/net/context" "google.golang.org/api/identitytoolkit/v3" @@ -39,6 +40,7 @@ var commonValidators = map[string]func(interface{}) error{ "password": validatePassword, "photoUrl": validatePhotoURL, "localId": validateUID, + "validSince": func(interface{}) error { return nil }, // Needed for preparePayload. } // Create a new interface @@ -65,6 +67,7 @@ type UserInfo struct { } // UserMetadata contains additional metadata associated with a user account. +// Timestamps are in milliseconds since epoch. type UserMetadata struct { CreationTimestamp int64 LastLogInTimestamp int64 @@ -73,11 +76,12 @@ type UserMetadata struct { // UserRecord contains metadata associated with a Firebase user account. type UserRecord struct { *UserInfo - CustomClaims map[string]interface{} - Disabled bool - EmailVerified bool - ProviderUserInfo []*UserInfo - UserMetadata *UserMetadata + CustomClaims map[string]interface{} + Disabled bool + EmailVerified bool + ProviderUserInfo []*UserInfo + TokensValidAfterMillis int64 // milliseconds since epoch. + UserMetadata *UserMetadata } // ExportedUserRecord is the returned user value used when listing all the users. @@ -173,6 +177,13 @@ func (u *UserToUpdate) PhoneNumber(phone string) *UserToUpdate { u.set("phoneNum // PhotoURL setter. func (u *UserToUpdate) PhotoURL(url string) *UserToUpdate { u.set("photoUrl", url); return u } +// revokeRefreshTokens revokes all refresh tokens for a user by setting the validSince property +// to the present in epoch seconds. +func (u *UserToUpdate) revokeRefreshTokens() *UserToUpdate { + u.set("validSince", time.Now().Unix()) + return u +} + // CreateUser creates a new user with the specified properties. func (c *Client) CreateUser(ctx context.Context, user *UserToCreate) (*UserRecord, error) { uid, err := c.createUser(ctx, user) @@ -471,7 +482,12 @@ func (u *UserToUpdate) preparePayload(user *identitytoolkit.IdentitytoolkitRelyi if err := validate(v); err != nil { return err } - reflect.ValueOf(user).Elem().FieldByName(strings.Title(key)).SetString(params[key].(string)) + f := reflect.ValueOf(user).Elem().FieldByName(strings.Title(key)) + if f.Kind() == reflect.String { + f.SetString(params[key].(string)) + } else if f.Kind() == reflect.Int64 { + f.SetInt(params[key].(int64)) + } } } if params["disableUser"] != nil { @@ -528,7 +544,6 @@ 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") } - request := &identitytoolkit.IdentitytoolkitRelyingpartySetAccountInfoRequest{ LocalId: uid, } @@ -597,10 +612,11 @@ func makeExportedUser(r *identitytoolkit.UserInfo) (*ExportedUserRecord, error) ProviderID: defaultProviderID, UID: r.LocalId, }, - CustomClaims: cc, - Disabled: r.Disabled, - EmailVerified: r.EmailVerified, - ProviderUserInfo: providerUserInfo, + CustomClaims: cc, + Disabled: r.Disabled, + EmailVerified: r.EmailVerified, + ProviderUserInfo: providerUserInfo, + TokensValidAfterMillis: r.ValidSince * 1000, UserMetadata: &UserMetadata{ LastLogInTimestamp: r.LastLoginAt, CreationTimestamp: r.CreatedAt, diff --git a/auth/user_mgt_test.go b/auth/user_mgt_test.go index 5c75fdf9..db7b7300 100644 --- a/auth/user_mgt_test.go +++ b/auth/user_mgt_test.go @@ -24,6 +24,7 @@ import ( "reflect" "strings" "testing" + "time" "firebase.google.com/go/internal" @@ -59,9 +60,10 @@ var testUser = &UserRecord{ UID: "testuid", }, }, + TokensValidAfterMillis: 1494364393000, UserMetadata: &UserMetadata{ - CreationTimestamp: 1234567890, - LastLogInTimestamp: 1233211232, + CreationTimestamp: 1234567890000, + LastLogInTimestamp: 1233211232000, }, CustomClaims: map[string]interface{}{"admin": true, "package": "gold"}, } @@ -496,6 +498,41 @@ func TestUpdateUser(t *testing.T) { } } } +func TestRevokeRefreshTokens(t *testing.T) { + resp := `{ + "kind": "identitytoolkit#SetAccountInfoResponse", + "localId": "expectedUserID" + }` + s := echoServer([]byte(resp), t) + defer s.Close() + before := time.Now().Unix() + if err := s.Client.RevokeRefreshTokens(context.Background(), "some_uid"); err != nil { + t.Error(err) + } + after := time.Now().Unix() + + req := &identitytoolkit.IdentitytoolkitRelyingpartySetAccountInfoRequest{} + if err := json.Unmarshal(s.Rbody, &req); err != nil { + t.Error(err) + } + if req.ValidSince > after || req.ValidSince < before { + t.Errorf("validSince = %d, expecting time between %d and %d", req.ValidSince, before, after) + } +} + +func TestRevokeRefreshTokensInvalidUID(t *testing.T) { + resp := `{ + "kind": "identitytoolkit#SetAccountInfoResponse", + "localId": "expectedUserID" + }` + s := echoServer([]byte(resp), t) + defer s.Close() + + we := "uid must not be empty" + if err := s.Client.RevokeRefreshTokens(context.Background(), ""); err == nil || err.Error() != we { + t.Errorf("RevokeRefreshTokens(); err = %s; want err = %s", err.Error(), we) + } +} func TestInvalidSetCustomClaims(t *testing.T) { cases := []struct { @@ -609,8 +646,8 @@ func TestMakeExportedUser(t *testing.T) { PasswordHash: "passwordhash", ValidSince: 1494364393, Disabled: false, - CreatedAt: 1234567890, - LastLoginAt: 1233211232, + CreatedAt: 1234567890000, + LastLoginAt: 1233211232000, CustomAttributes: `{"admin": true, "package": "gold"}`, ProviderUserInfo: []*identitytoolkit.UserInfoProviderUserInfo{ { @@ -637,7 +674,8 @@ func TestMakeExportedUser(t *testing.T) { } if !reflect.DeepEqual(exported.UserRecord, want.UserRecord) { // zero in - t.Errorf("makeExportedUser() = %#v; want: %#v", exported.UserRecord, want.UserRecord) + t.Errorf("makeExportedUser() = %#v; want: %#v \n(%#v)\n(%#v)", exported.UserRecord, want.UserRecord, + exported.UserMetadata, want.UserMetadata) } if exported.PasswordHash != want.PasswordHash { t.Errorf("PasswordHash = %q; want = %q", exported.PasswordHash, want.PasswordHash) @@ -690,8 +728,7 @@ func echoServer(resp interface{}, t *testing.T) *mockAuthServer { case []byte: b = v default: - b, err = json.Marshal(resp) - if err != nil { + if b, err = json.Marshal(resp); err != nil { t.Fatal("marshaling error") } } @@ -729,17 +766,17 @@ func echoServer(resp interface{}, t *testing.T) *mockAuthServer { } w.Header().Set("Content-Type", "application/json") w.Write(s.Resp) - }) s.Srv = httptest.NewServer(handler) - conf := &internal.AuthConfig{ Opts: []option.ClientOption{ - option.WithTokenSource(&mockTokenSource{testToken}), - }, - Version: testVersion, + option.WithTokenSource(&mockTokenSource{testToken})}, + ProjectID: "mock-project-id", + Version: testVersion, } - authClient, err := NewClient(context.Background(), conf) + + authClient, err := NewClient(ctx, conf) + authClient.ks = &fileKeySource{FilePath: "../testdata/public_certs.json"} if err != nil { t.Fatal(err) } diff --git a/integration/auth/auth_test.go b/integration/auth/auth_test.go index ee0dc0b3..5e027d44 100644 --- a/integration/auth/auth_test.go +++ b/integration/auth/auth_test.go @@ -25,6 +25,7 @@ import ( "net/http" "os" "testing" + "time" "firebase.google.com/go/auth" "firebase.google.com/go/integration/internal" @@ -62,7 +63,6 @@ func TestCustomToken(t *testing.T) { if err != nil { t.Fatal(err) } - idt, err := signInWithCustomToken(ct) if err != nil { t.Fatal(err) @@ -75,10 +75,69 @@ func TestCustomToken(t *testing.T) { if vt.UID != "user1" { t.Errorf("UID = %q; want UID = %q", vt.UID, "user1") } + if err = client.DeleteUser(context.Background(), "user1"); err != nil { + t.Error(err) + } +} + +func TestVerifyIDTokenAndCheckRevoked(t *testing.T) { + uid := "user_revoked" + ct, err := client.CustomToken(uid) + + if err != nil { + t.Fatal(err) + } + idt, err := signInWithCustomToken(ct) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + vt, err := client.VerifyIDTokenAndCheckRevoked(ctx, idt) + if err != nil { + t.Fatal(err) + } + if vt.UID != uid { + t.Errorf("UID = %q; want UID = %q", vt.UID, uid) + } + // The backend stores the validSince property in seconds since the epoch. + // The issuedAt property of the token is also in seconds. If a token was + // issued, and then in the same second tokens were revoked, the token will + // have the same timestamp as the tokensValidAfterMillis, and will therefore + // not be considered revoked. Hence we wait one second before revoking. + time.Sleep(time.Second) + if err = client.RevokeRefreshTokens(ctx, uid); err != nil { + t.Fatal(err) + } + + vt, err = client.VerifyIDTokenAndCheckRevoked(ctx, idt) + we := "ID token has been revoked" + if vt != nil || err == nil || err.Error() != we { + t.Errorf("tok, err := VerifyIDTokenAndCheckRevoked(); got (%v, %s) ; want (%v, %v)", + vt, err, nil, we) + } + + // Does not return error for revoked token. + if _, err = client.VerifyIDToken(idt); err != nil { + t.Errorf("VerifyIDToken(); err = %s; want err = ", err) + } + + // Sign in after revocation. + if idt, err = signInWithCustomToken(ct); err != nil { + t.Fatal(err) + } + + if _, err = client.VerifyIDTokenAndCheckRevoked(ctx, idt); err != nil { + t.Errorf("VerifyIDTokenAndCheckRevoked(); err = %s; want err = ", err) + } + + err = client.DeleteUser(ctx, uid) + if err != nil { + t.Error(err) + } } func TestCustomTokenWithClaims(t *testing.T) { - ct, err := client.CustomTokenWithClaims("user1", map[string]interface{}{ + ct, err := client.CustomTokenWithClaims("user2", map[string]interface{}{ "premium": true, "package": "gold", }) @@ -95,8 +154,8 @@ func TestCustomTokenWithClaims(t *testing.T) { if err != nil { t.Fatal(err) } - if vt.UID != "user1" { - t.Errorf("UID = %q; want UID = %q", vt.UID, "user1") + if vt.UID != "user2" { + t.Errorf("UID = %q; want UID = %q", vt.UID, "user2") } if premium, ok := vt.Claims["premium"].(bool); !ok || !premium { t.Errorf("Claims['premium'] = %v; want Claims['premium'] = true", vt.Claims["premium"]) @@ -104,6 +163,9 @@ func TestCustomTokenWithClaims(t *testing.T) { if pkg, ok := vt.Claims["package"].(string); !ok || pkg != "gold" { t.Errorf("Claims['package'] = %v; want Claims['package'] = \"gold\"", vt.Claims["package"]) } + if err = client.DeleteUser(context.Background(), "user2"); err != nil { + t.Error(err) + } } func signInWithCustomToken(token string) (string, error) { diff --git a/integration/auth/user_mgt_test.go b/integration/auth/user_mgt_test.go index 8227599f..3013fbe0 100644 --- a/integration/auth/user_mgt_test.go +++ b/integration/auth/user_mgt_test.go @@ -19,6 +19,7 @@ import ( "fmt" "reflect" "testing" + "time" "google.golang.org/api/iterator" @@ -34,16 +35,30 @@ var testFixtures = struct { }{} func TestUserManagement(t *testing.T) { - t.Run("Create test users", testCreateUsers) - t.Run("Get user", testGetUser) - t.Run("Iterate users", testUserIterator) - t.Run("Paged iteration", testPager) - t.Run("Disable user account", testDisableUser) - t.Run("Update user", testUpdateUser) - t.Run("Remove user attributes", testRemovePhonePhotoName) - t.Run("Remove custom claims", testRemoveCustomClaims) - t.Run("Add custom claims", testAddCustomClaims) - t.Run("Delete test users", testDeleteUsers) + orderedRuns := []struct { + name string + testFunc func(*testing.T) + }{ + {"Create test users", testCreateUsers}, + {"Get user", testGetUser}, + {"Iterate users", testUserIterator}, + {"Paged iteration", testPager}, + {"Disable user account", testDisableUser}, + {"Update user", testUpdateUser}, + {"Remove user attributes", testRemovePhonePhotoName}, + {"Remove custom claims", testRemoveCustomClaims}, + {"Add custom claims", testAddCustomClaims}, + {"Delete test users", testDeleteUsers}, + } + // The tests are meant to be run in sequence. A failure in creating the users + // should be fatal so non of the other tests run. However calling Fatal from a + // subtest does not prevent the other subtests from running, hence we check the + // success of each subtest before proceeding. + for _, run := range orderedRuns { + if ok := t.Run(run.name, run.testFunc); !ok { + t.Fatalf("Failed run %v", run.name) + } + } } // N.B if the tests are failing due to inability to create existing users, manual @@ -52,14 +67,22 @@ func TestUserManagement(t *testing.T) { func testCreateUsers(t *testing.T) { // Create users with uid for i := 0; i < 3; i++ { - params := (&auth.UserToCreate{}).UID(fmt.Sprintf("tempTestUserID-%d", i)) + uid := fmt.Sprintf("tempTestUserID-%d", i) + params := (&auth.UserToCreate{}).UID(uid) u, err := client.CreateUser(context.Background(), params) if err != nil { - t.Fatal("failed to create user", i, err) + t.Fatal(err) } testFixtures.uidList = append(testFixtures.uidList, u.UID) - } + // make sure that the user.TokensValidAfterMillis is not in the future or stale. + if u.TokensValidAfterMillis > time.Now().Unix()*1000 { + t.Errorf("timestamp cannot be in the future") + } + if time.Now().Sub(time.Unix(u.TokensValidAfterMillis, 0)) > time.Hour { + t.Errorf("timestamp should be recent") + } + } // Create user with no parameters (zero-value) u, err := client.CreateUser(context.Background(), (&auth.UserToCreate{})) if err != nil { @@ -75,8 +98,8 @@ func testCreateUsers(t *testing.T) { Email(uid + "email@test.com"). DisplayName("display_name"). Password("password") - u, err = client.CreateUser(context.Background(), params) - if err != nil { + + if u, err = client.CreateUser(context.Background(), params); err != nil { t.Fatal(err) } testFixtures.sampleUserWithData = u @@ -85,6 +108,7 @@ func testCreateUsers(t *testing.T) { func testGetUser(t *testing.T) { want := testFixtures.sampleUserWithData + u, err := client.GetUser(context.Background(), want.UID) if err != nil { t.Fatalf("error getting user %s", err) @@ -216,12 +240,13 @@ func testUpdateUser(t *testing.T) { UID: testFixtures.sampleUserBlank.UID, ProviderID: "firebase", }, + TokensValidAfterMillis: u.TokensValidAfterMillis, UserMetadata: &auth.UserMetadata{ CreationTimestamp: testFixtures.sampleUserBlank.UserMetadata.CreationTimestamp, }, } if !reflect.DeepEqual(u, want) { - t.Errorf("GetUser() = %v; want = %v", u, want) + t.Errorf("GetUser() = %#v; want = %#v", u, want) } params := (&auth.UserToUpdate{}). @@ -247,6 +272,7 @@ func testUpdateUser(t *testing.T) { ProviderID: "firebase", Email: "abc@ab.ab", }, + TokensValidAfterMillis: u.TokensValidAfterMillis, UserMetadata: &auth.UserMetadata{ CreationTimestamp: testFixtures.sampleUserBlank.UserMetadata.CreationTimestamp, }, @@ -289,7 +315,7 @@ func testUpdateUser(t *testing.T) { // now compare the rest of the record, without the ProviderInfo u.ProviderUserInfo = nil if !reflect.DeepEqual(u, want) { - t.Errorf("UpdateUser() = %v; want = %v", u, want) + t.Errorf("UpdateUser() = %#v; want = %#v", u, want) } } diff --git a/testdata/get_user.json b/testdata/get_user.json index a56ef9f3..a62102e0 100644 --- a/testdata/get_user.json +++ b/testdata/get_user.json @@ -28,8 +28,8 @@ "passwordUpdatedAt": 1.494364393E+12, "validSince": "1494364393", "disabled": false, - "createdAt": "1234567890", - "lastLoginAt": "1233211232", + "createdAt": "1234567890000", + "lastLoginAt": "1233211232000", "customAttributes": "{\"admin\": true, \"package\": \"gold\"}" } ] diff --git a/testdata/list_users.json b/testdata/list_users.json index 21d152fc..a0c625ef 100644 --- a/testdata/list_users.json +++ b/testdata/list_users.json @@ -28,8 +28,8 @@ "passwordUpdatedAt": 1.494364393E+12, "validSince": "1494364393", "disabled": false, - "createdAt": "1234567890", - "lastLoginAt": "1233211232", + "createdAt": "1234567890000", + "lastLoginAt": "1233211232000", "customAttributes": "{\"admin\": true, \"package\": \"gold\"}" }, { @@ -59,8 +59,8 @@ "passwordUpdatedAt": 1.494364393E+12, "validSince": "1494364393", "disabled": false, - "createdAt": "1234567890", - "lastLoginAt": "1233211232", + "createdAt": "1234567890000", + "lastLoginAt": "1233211232000", "customAttributes": "{\"admin\": true, \"package\": \"gold\"}" }, { @@ -90,8 +90,8 @@ "passwordUpdatedAt": 1.494364393E+12, "validSince": "1494364393", "disabled": false, - "createdAt": "1234567890", - "lastLoginAt": "1233211232", + "createdAt": "1234567890000", + "lastLoginAt": "1233211232000", "customAttributes": "{\"admin\": true, \"package\": \"gold\"}" } ],