From 09fc70b6de09fd63b2b47d4df0574675c9e83493 Mon Sep 17 00:00:00 2001 From: Hiranya Jayathilaka Date: Wed, 17 Jan 2018 10:17:22 -0800 Subject: [PATCH 1/9] Renamed some tests and test parameters for clarity, and adhere to Go conventions (#74) --- firebase_test.go | 95 +++++++++++++---------- testdata/firebase_config.json | 4 +- testdata/firebase_config_invalid_key.json | 4 +- testdata/firebase_config_partial.json | 2 +- 4 files changed, 61 insertions(+), 44 deletions(-) diff --git a/firebase_test.go b/firebase_test.go index 686d6af5..df41c56d 100644 --- a/firebase_test.go +++ b/firebase_test.go @@ -15,6 +15,7 @@ package firebase import ( + "fmt" "io/ioutil" "log" "net/http" @@ -355,41 +356,46 @@ func TestAutoInit(t *testing.T) { 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", + ProjectID: "auto-init-project-id", + StorageBucket: "auto-init.storage.bucket", }, - }, { - "Environment variable set to string, no explicit options", + }, + { + "", `{ - "projectId": "hipster-chat-mock", - "storageBucket": "hipster-chat.appspot.mock" + "projectId": "auto-init-project-id", + "storageBucket": "auto-init.storage.bucket" }`, nil, &Config{ - ProjectID: "hipster-chat-mock", - StorageBucket: "hipster-chat.appspot.mock", + ProjectID: "auto-init-project-id", + StorageBucket: "auto-init.storage.bucket", }, - }, { - "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"}`, + &Config{ProjectID: "auto-init-project-id"}, + }, + { + "", + `{"projectId": "auto-init-project-id"}`, nil, - &Config{ProjectID: "hipster-chat-mock"}, - }, { - "Environment variable set to file which is ignored as some explicit options are passed", + &Config{ProjectID: "auto-init-project-id"}, + }, + { + "", "testdata/firebase_config_partial.json", &Config{StorageBucket: "sb1-mock"}, &Config{ @@ -397,36 +403,45 @@ func TestAutoInit(t *testing.T) { StorageBucket: "sb1-mock", }, }, { - "Environment variable set to string which is ignored as some explicit options are passed", - `{"projectId": "hipster-chat-mock"}`, + "", + `{"projectId": "auto-init-project-id"}`, &Config{StorageBucket: "sb1-mock"}, &Config{ - ProjectID: "mock-project-id", + ProjectID: "mock-project-id", // from default creds 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", + }, + { + "", + `{"projectId": "auto-init-project-id"}`, + &Config{}, + &Config{ProjectID: "mock-project-id"}, + }, + { + "", "testdata/firebase_config_invalid_key.json", nil, &Config{ ProjectID: "mock-project-id", // from default creds - StorageBucket: "hipster-chat.appspot.mock", + StorageBucket: "auto-init.storage.bucket", }, - }, { - "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" + "obviously_bad_key": "mock-project-id", + "storageBucket": "auto-init.storage.bucket" }`, nil, &Config{ ProjectID: "mock-project-id", - StorageBucket: "hipster-chat.appspot.mock", + StorageBucket: "auto-init.storage.bucket", }, }, } @@ -435,7 +450,7 @@ func TestAutoInit(t *testing.T) { defer reinstateEnv(credEnvVar, credOld) for _, test := range tests { - t.Run(test.name, func(t *testing.T) { + t.Run(fmt.Sprintf("NewApp(%s)", test.name), func(t *testing.T) { overwriteEnv(firebaseEnvName, test.optionsConfig) app, err := NewApp(context.Background(), test.initOptions) if err != nil { @@ -454,15 +469,17 @@ func TestAutoInitInvalidFiles(t *testing.T) { wantError string }{ { - "nonexistant file", + "NonexistingFile", "testdata/no_such_file.json", "open testdata/no_such_file.json: no such file or directory", - }, { - "invalid JSON", + }, + { + "InvalidJSON", "testdata/firebase_config_invalid.json", "invalid character 'b' looking for beginning of value", - }, { - "empty file", + }, + { + "EmptyFile", "testdata/firebase_config_empty.json", "unexpected end of JSON input", }, diff --git a/testdata/firebase_config.json b/testdata/firebase_config.json index d249fe76..e9a3b5bc 100644 --- a/testdata/firebase_config.json +++ b/testdata/firebase_config.json @@ -1,4 +1,4 @@ { - "projectId": "hipster-chat-mock", - "storageBucket": "hipster-chat.appspot.mock" + "projectId": "auto-init-project-id", + "storageBucket": "auto-init.storage.bucket" } diff --git a/testdata/firebase_config_invalid_key.json b/testdata/firebase_config_invalid_key.json index 8fad82c8..6cbc52f4 100644 --- a/testdata/firebase_config_invalid_key.json +++ b/testdata/firebase_config_invalid_key.json @@ -1,4 +1,4 @@ { - "project1d_bad_key": "hipster-chat-mock", - "storageBucket": "hipster-chat.appspot.mock" + "project1d_bad_key": "auto-init-project-id", + "storageBucket": "auto-init.storage.bucket" } diff --git a/testdata/firebase_config_partial.json b/testdata/firebase_config_partial.json index 1775043e..8515413f 100644 --- a/testdata/firebase_config_partial.json +++ b/testdata/firebase_config_partial.json @@ -1,3 +1,3 @@ { - "projectId": "hipster-chat-mock" + "projectId": "auto-init-project-id" } From c24cb17191866a3a11a365b9b36ad9af3adf70ba Mon Sep 17 00:00:00 2001 From: avishalom Date: Fri, 19 Jan 2018 15:29:18 -0500 Subject: [PATCH 2/9] clean unused types (#76) --- auth/user_mgt.go | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/auth/user_mgt.go b/auth/user_mgt.go index 9d0098dc..eb92d9a5 100644 --- a/auth/user_mgt.go +++ b/auth/user_mgt.go @@ -498,37 +498,6 @@ func (u *UserToUpdate) preparePayload(user *identitytoolkit.IdentitytoolkitRelyi // End of validators -// Response Types ------------------------------- - -type getUserResponse struct { - RequestType string - Users []responseUserRecord -} - -type responseUserRecord struct { - 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 - Users []responseUserRecord - NextPage string -} - // Helper functions for retrieval and HTTP calls. func (c *Client) createUser(ctx context.Context, user *UserToCreate) (string, error) { From fb6fa29bdc92e08deeee70b1f2728177984d0aa1 Mon Sep 17 00:00:00 2001 From: avishalom Date: Mon, 29 Jan 2018 14:52:09 -0500 Subject: [PATCH 3/9] Create CHANGELOG.md (#75) (#79) * Create CHANGELOG.md Initial changelog based on https://firebase.google.com/support/release-notes/admin/go --- CHANGELOG.md | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..3ee0af8b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,101 @@ +# Unreleased +- + +# v2.4.0 + +### Initialization + +- [added] The [`firebase.NewApp()`](https://godoc.org/firebase.google.com/go#NewApp) + method can now be invoked without any arguments. This initializes an app + using Google Application Default Credentials, and + [`firebase.Config`](https://godoc.org/firebase.google.com/go#Config) loaded + from the `FIREBASE_CONFIG` environment variable. + +### Authentication + +- [changed] The user management operations in the `auth` package now uses the + [`identitytoolkit/v3`](https://google.golang.org/api/identitytoolkit/v3) library. +- [changed] The `ProviderID` field on the + [`auth.UserRecord`](https://godoc.org/firebase.google.com/go/auth#UserRecord) + type is now set to the constant value `firebase`. + +# v2.3.0 + +- [added] A new [`InstanceID`](https://godoc.org/firebase.google.com/go#App.InstanceID) + API that facilitates deleting instance IDs and associated user data from + Firebase projects. + +# v2.2.1 + +### Authentication + +- [changed] Adding the `X-Client-Version` to the headers in the API calls for + tracking API usage. + +# v2.2.0 + +### Authentication + +- [added] A new user management API that supports querying and updating + user accounts associated with a Firebase project. This adds `GetUser()`, + `GetUserByEmail()`, `GetUserByPhoneNumber()`, `CreateUser()`, `UpdateUser()`, + `DeleteUser()`, `Users()` and `SetCustomUserClaims()` functions to the + [`auth.Client`](https://godoc.org/firebase.google.com/go/auth#Client) API. + +# v2.1.0 + +- [added] A new [`Firestore` API](https://godoc.org/firebase.google.com/go#App.Firestore) + that enables access to [Cloud Firestore](/docs/firestore) databases. + +# v2.0.0 + +- [added] A new [Cloud Storage API](https://godoc.org/firebase.google.com/go/storage) + that facilitates accessing Google Cloud Storage buckets using the + [`cloud.google.com/go/storage`](https://cloud.google.com/go/storage) + package. + +### Authentication + +- [changed] The [`Auth()`](https://godoc.org/firebase.google.com/go#App.Auth) + API now accepts a `Context` argument. This breaking + change enables passing different contexts to different services, instead + of using a single context per [`App`](https://godoc.org/firebase.google.com/go#App). + +# v1.0.2 + +### Authentication + +- [changed] When deployed in the Google App Engine environment, the SDK can + now leverage the utilities provided by the + [App Engine SDK](https://cloud.google.com/appengine/docs/standard/go/reference) + to sign JWT tokens. As a result, it is now possible to initialize the Admin + SDK in App Engine without a service account JSON file, and still be able to + call [`CustomToken()`](https://godoc.org/firebase.google.com/go/auth#Client.CustomToken) + and [`CustomTokenWithClaims()`](https://godoc.org/firebase.google.com/go/auth#Client.CustomTokenWithClaims). + +# v1.0.1 + +### Authentication + +- [changed] Now uses the client options provided during + [SDK initialization](https://godoc.org/firebase.google.com/go#NewApp) to + create the [`http.Client`](https://godoc.org/net/http#Client) that is used + to fetch public key certificates. This enables developers to use the ID token + verification feature in environments like Google App Engine by providing a + platform-specific `http.Client` using + [`option.WithHTTPClient()`](https://godoc.org/google.golang.org/api/option#WithHTTPClient). + +# v1.0.0 + +- [added] Initial release of the Admin Go SDK. See + [Add the Firebase Admin SDK to your Server](/docs/admin/setup/) to get + started. +- [added] You can configure the SDK to use service account credentials, user + credentials (refresh tokens), or Google Cloud application default credentials + to access your Firebase project. + +### Authentication + +- [added] The initial release includes the `CustomToken()`, + `CustomTokenWithClaims()`, and `VerifyIDToken()` functions for minting custom + authentication tokens and verifying Firebase ID tokens. From aae4f9321d4f1e6ce2864d1fefc96e2160afbac0 Mon Sep 17 00:00:00 2001 From: avishalom Date: Thu, 1 Feb 2018 14:16:03 -0500 Subject: [PATCH 4/9] change instance ID format (#82) Changing the format of the "non-existing" instance ID in the integration tests to comply with the expected iid format. --- integration/iid/iid_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/integration/iid/iid_test.go b/integration/iid/iid_test.go index 8b7b316c..d3303dc4 100644 --- a/integration/iid/iid_test.go +++ b/integration/iid/iid_test.go @@ -50,12 +50,12 @@ func TestMain(m *testing.M) { } func TestNonExisting(t *testing.T) { - err := client.DeleteInstanceID(context.Background(), "non-existing") + err := client.DeleteInstanceID(context.Background(), "dnon-existY") if err == nil { - t.Errorf("DeleteInstanceID(non-existing) = nil; want error") + t.Errorf("DeleteInstanceID(\"dnon-existY\") = nil; want error") } - want := `instance id "non-existing": failed to find the instance id` + want := `instance id "dnon-existY": failed to find the instance id` if err.Error() != want { - t.Errorf("DeleteInstanceID(non-existing) = %v; want = %v", err, want) + t.Errorf("DeleteInstanceID(\"dnon-existY\") = %v; want = %v", err, want) } } From 56a731253688f584f110829f705d9cd91b9ec7e1 Mon Sep 17 00:00:00 2001 From: avishalom Date: Thu, 8 Feb 2018 12:39:52 -0500 Subject: [PATCH 5/9] Import context from golang.org/x/net/ for 1.6 compatibility (#87) * import golang.org/x/net/context instead of context for 1.6 compatibility --- CHANGELOG.md | 2 +- auth/auth_std.go | 2 +- integration/firestore/firestore_test.go | 3 ++- integration/iid/iid_test.go | 3 ++- storage/storage.go | 3 ++- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ee0af8b..754e2f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ # Unreleased -- +- Import context from golang.org/x/net/ for 1.6 compatibility # v2.4.0 diff --git a/auth/auth_std.go b/auth/auth_std.go index f593a7cc..2055af38 100644 --- a/auth/auth_std.go +++ b/auth/auth_std.go @@ -16,7 +16,7 @@ package auth -import "context" +import "golang.org/x/net/context" func newSigner(ctx context.Context) (signer, error) { return serviceAcctSigner{}, nil diff --git a/integration/firestore/firestore_test.go b/integration/firestore/firestore_test.go index 6c367205..6e7b4e28 100644 --- a/integration/firestore/firestore_test.go +++ b/integration/firestore/firestore_test.go @@ -15,12 +15,13 @@ package firestore import ( - "context" "log" "reflect" "testing" "firebase.google.com/go/integration/internal" + + "golang.org/x/net/context" ) func TestFirestore(t *testing.T) { diff --git a/integration/iid/iid_test.go b/integration/iid/iid_test.go index d3303dc4..02132925 100644 --- a/integration/iid/iid_test.go +++ b/integration/iid/iid_test.go @@ -16,7 +16,6 @@ package iid import ( - "context" "flag" "log" "os" @@ -24,6 +23,8 @@ import ( "firebase.google.com/go/iid" "firebase.google.com/go/integration/internal" + + "golang.org/x/net/context" ) var client *iid.Client diff --git a/storage/storage.go b/storage/storage.go index 878e2175..985b6eb7 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -16,11 +16,12 @@ package storage import ( - "context" "errors" "cloud.google.com/go/storage" "firebase.google.com/go/internal" + + "golang.org/x/net/context" ) // Client is the interface for the Firebase Storage service. From c764f496cf24721a775b727893a662821f59dc0b Mon Sep 17 00:00:00 2001 From: avishalom Date: Thu, 8 Feb 2018 12:44:37 -0500 Subject: [PATCH 6/9] Document non existing name in integration tests for iid (#85) --- integration/iid/iid_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/integration/iid/iid_test.go b/integration/iid/iid_test.go index 02132925..9be5dce0 100644 --- a/integration/iid/iid_test.go +++ b/integration/iid/iid_test.go @@ -51,12 +51,13 @@ func TestMain(m *testing.M) { } func TestNonExisting(t *testing.T) { - err := client.DeleteInstanceID(context.Background(), "dnon-existY") + // legal instance IDs are /[cdef][A-Za-z0-9_-]{9}[AEIMQUYcgkosw048]/ + err := client.DeleteInstanceID(context.Background(), "fictive-ID0") if err == nil { - t.Errorf("DeleteInstanceID(\"dnon-existY\") = nil; want error") + t.Errorf("DeleteInstanceID(non-existing) = nil; want error") } - want := `instance id "dnon-existY": failed to find the instance id` + want := `instance id "fictive-ID0": failed to find the instance id` if err.Error() != want { - t.Errorf("DeleteInstanceID(\"dnon-existY\") = %v; want = %v", err, want) + t.Errorf("DeleteInstanceID(non-existing) = %v; want = %v", err, want) } } From 06eb0e061a430e2889763709ed4fa698308b98a9 Mon Sep 17 00:00:00 2001 From: avishalom Date: Mon, 12 Feb 2018 20:33:52 -0500 Subject: [PATCH 7/9] Revoke Tokens (#77) Adding TokensValidAfterMillis property, RevokeRefreshTokens(), and VerifyIDTokenAndCheckRevoked(). --- CHANGELOG.md | 10 +++++ auth/auth.go | 34 +++++++++++++++ auth/auth_test.go | 49 +++++++++++++++++++++- auth/user_mgt.go | 38 ++++++++++++----- auth/user_mgt_test.go | 63 ++++++++++++++++++++++------ integration/auth/auth_test.go | 70 +++++++++++++++++++++++++++++-- integration/auth/user_mgt_test.go | 60 ++++++++++++++++++-------- testdata/get_user.json | 4 +- testdata/list_users.json | 12 +++--- 9 files changed, 285 insertions(+), 55 deletions(-) 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\"}" } ], From 3f7b4ba80820fff3d03d8a4418d2beb03fff9720 Mon Sep 17 00:00:00 2001 From: Hiranya Jayathilaka Date: Mon, 12 Feb 2018 18:17:54 -0800 Subject: [PATCH 8/9] Firebase Cloud Messaging API (#81) * Adding Firebase Cloud Messaging (#62) * initial commit for adding Firebase Cloud Messaging * add validator * use http const in messaging test * add client version header for stats * init integration test * add integration test (validated on IOS today) * add comment with URL to enable Firebase Cloud Messaging API * fix broken test * add integration tests * accept a Message instead of RequestMessage + and rename method + send / sendDryRun * update fcm url * rollback url endpoint * fix http constants, change responseMessage visibility, change map[string]interface{} as map[string]string * fix http constants * fix integration tests * fix APNS naming * add validators * Added APNS types; Updated tests * Added more tests; Fixed APNS serialization * Updated documentation * Improved error handling inFCM * Added utils file * Updated integration tests * Implemented topic management operations * Added integration tests * Updated CHANGELOG * Addressing code review comments * Supporting 0 valued Aps.Badge * Addressing some review comments * Removed some unused vars * Accepting prefixed topic names (#84) * Accepting prefixed topic named * Added a comment * Using new FCM error codes (#89) --- CHANGELOG.md | 24 +- firebase.go | 12 + firebase_test.go | 12 + integration/messaging/messaging_test.go | 125 ++++ internal/internal.go | 7 + messaging/messaging.go | 475 +++++++++++++ messaging/messaging_test.go | 870 ++++++++++++++++++++++++ messaging/messaging_utils.go | 131 ++++ 8 files changed, 1647 insertions(+), 9 deletions(-) create mode 100644 integration/messaging/messaging_test.go create mode 100644 messaging/messaging.go create mode 100644 messaging/messaging_test.go create mode 100644 messaging/messaging_utils.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a8c4af..ea15a5f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,22 @@ # 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 +### Cloud Messaging + +- [feature] Added the `messaging` package for sending Firebase notifications + and managing topic subscriptions. + +### Authentication + +- [added] A new [`VerifyIDTokenAndCheckRevoked()`](https://godoc.org/firebase.google.com/go/auth#Client.VerifyIDToken) + function has been added to check for revoked ID tokens. +- [added] A new [`RevokeRefreshTokens()`](https://godoc.org/firebase.google.com/go/auth#Client.RevokeRefreshTokens) + function 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) + type, which stores the time of the revocation truncated to 1 second accuracy. + # v2.4.0 ### Initialization diff --git a/firebase.go b/firebase.go index a0e5085c..9ed07125 100644 --- a/firebase.go +++ b/firebase.go @@ -28,6 +28,7 @@ import ( "firebase.google.com/go/auth" "firebase.google.com/go/iid" "firebase.google.com/go/internal" + "firebase.google.com/go/messaging" "firebase.google.com/go/storage" "golang.org/x/net/context" @@ -43,6 +44,7 @@ var firebaseScopes = []string{ "https://www.googleapis.com/auth/firebase", "https://www.googleapis.com/auth/identitytoolkit", "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/firebase.messaging", } // Version of the Firebase Go Admin SDK. @@ -103,6 +105,16 @@ func (a *App) InstanceID(ctx context.Context) (*iid.Client, error) { return iid.NewClient(ctx, conf) } +// Messaging returns an instance of messaging.Client. +func (a *App) Messaging(ctx context.Context) (*messaging.Client, error) { + conf := &internal.MessagingConfig{ + ProjectID: a.projectID, + Opts: a.opts, + Version: Version, + } + return messaging.NewClient(ctx, conf) +} + // NewApp creates a new App from the provided config and client options. // // If the client options contain a valid credential (a service account file, a refresh token diff --git a/firebase_test.go b/firebase_test.go index df41c56d..fc33ba20 100644 --- a/firebase_test.go +++ b/firebase_test.go @@ -304,6 +304,18 @@ func TestInstanceID(t *testing.T) { } } +func TestMessaging(t *testing.T) { + ctx := context.Background() + app, err := NewApp(ctx, nil, option.WithCredentialsFile("testdata/service_account.json")) + if err != nil { + t.Fatal(err) + } + + if c, err := app.Messaging(ctx); c == nil || err != nil { + t.Errorf("Messaging() = (%v, %v); want (iid, nil)", c, err) + } +} + func TestCustomTokenSource(t *testing.T) { ctx := context.Background() ts := &testTokenSource{AccessToken: "mock-token-from-custom"} diff --git a/integration/messaging/messaging_test.go b/integration/messaging/messaging_test.go new file mode 100644 index 00000000..d7bb0693 --- /dev/null +++ b/integration/messaging/messaging_test.go @@ -0,0 +1,125 @@ +// Copyright 2018 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package messaging + +import ( + "flag" + "log" + "os" + "regexp" + "testing" + + "golang.org/x/net/context" + + "firebase.google.com/go/integration/internal" + "firebase.google.com/go/messaging" +) + +// The registration token has the proper format, but is not valid (i.e. expired). The intention of +// these integration tests is to verify that the endpoints return the proper payload, but it is +// hard to ensure this token remains valid. The tests below should still pass regardless. +const testRegistrationToken = "fGw0qy4TGgk:APA91bGtWGjuhp4WRhHXgbabIYp1jxEKI08ofj_v1bKhWAGJQ4e3a" + + "rRCWzeTfHaLz83mBnDh0aPWB1AykXAVUUGl2h1wT4XI6XazWpvY7RBUSYfoxtqSWGIm2nvWh2BOP1YG501SsRoE" + +var client *messaging.Client + +// Enable API before testing +// https://console.developers.google.com/apis/library/fcm.googleapis.com +func TestMain(m *testing.M) { + flag.Parse() + if testing.Short() { + log.Println("Skipping messaging integration tests in short mode.") + return + } + + ctx := context.Background() + app, err := internal.NewTestApp(ctx) + if err != nil { + log.Fatalln(err) + } + + client, err = app.Messaging(ctx) + if err != nil { + log.Fatalln(err) + } + os.Exit(m.Run()) +} + +func TestSend(t *testing.T) { + msg := &messaging.Message{ + Topic: "foo-bar", + Notification: &messaging.Notification{ + Title: "Title", + Body: "Body", + }, + Android: &messaging.AndroidConfig{ + Notification: &messaging.AndroidNotification{ + Title: "Android Title", + Body: "Android Body", + }, + }, + APNS: &messaging.APNSConfig{ + Payload: &messaging.APNSPayload{ + Aps: &messaging.Aps{ + Alert: &messaging.ApsAlert{ + Title: "APNS Title", + Body: "APNS Body", + }, + }, + }, + }, + Webpush: &messaging.WebpushConfig{ + Notification: &messaging.WebpushNotification{ + Title: "Webpush Title", + Body: "Webpush Body", + }, + }, + } + name, err := client.SendDryRun(context.Background(), msg) + if err != nil { + log.Fatalln(err) + } + const pattern = "^projects/.*/messages/.*$" + if !regexp.MustCompile(pattern).MatchString(name) { + t.Errorf("Send() = %q; want = %q", name, pattern) + } +} + +func TestSendInvalidToken(t *testing.T) { + msg := &messaging.Message{Token: "INVALID_TOKEN"} + if _, err := client.Send(context.Background(), msg); err == nil { + t.Errorf("Send() = nil; want error") + } +} + +func TestSubscribe(t *testing.T) { + tmr, err := client.SubscribeToTopic(context.Background(), []string{testRegistrationToken}, "mock-topic") + if err != nil { + t.Fatal(err) + } + if tmr.SuccessCount+tmr.FailureCount != 1 { + t.Errorf("SubscribeToTopic() = %v; want total 1", tmr) + } +} + +func TestUnsubscribe(t *testing.T) { + tmr, err := client.UnsubscribeFromTopic(context.Background(), []string{testRegistrationToken}, "mock-topic") + if err != nil { + t.Fatal(err) + } + if tmr.SuccessCount+tmr.FailureCount != 1 { + t.Errorf("UnsubscribeFromTopic() = %v; want total 1", tmr) + } +} diff --git a/internal/internal.go b/internal/internal.go index 34c4f32d..225edc9e 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -46,6 +46,13 @@ type MockTokenSource struct { AccessToken string } +// MessagingConfig represents the configuration of Firebase Cloud Messaging service. +type MessagingConfig struct { + Opts []option.ClientOption + ProjectID string + Version string +} + // Token returns the test token associated with the TokenSource. func (ts *MockTokenSource) Token() (*oauth2.Token, error) { return &oauth2.Token{AccessToken: ts.AccessToken}, nil diff --git a/messaging/messaging.go b/messaging/messaging.go new file mode 100644 index 00000000..97b77d64 --- /dev/null +++ b/messaging/messaging.go @@ -0,0 +1,475 @@ +// Copyright 2018 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package messaging contains functions for sending messages and managing +// device subscriptions with Firebase Cloud Messaging (FCM). +package messaging + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "golang.org/x/net/context" + + "firebase.google.com/go/internal" + "google.golang.org/api/transport" +) + +const ( + messagingEndpoint = "https://fcm.googleapis.com/v1" + iidEndpoint = "https://iid.googleapis.com" + iidSubscribe = "iid/v1:batchAdd" + iidUnsubscribe = "iid/v1:batchRemove" +) + +var ( + topicNamePattern = regexp.MustCompile("^(/topics/)?(private/)?[a-zA-Z0-9-_.~%]+$") + + fcmErrorCodes = map[string]string{ + "INVALID_ARGUMENT": "request contains an invalid argument; code: invalid-argument", + "UNREGISTERED": "app instance has been unregistered; code: registration-token-not-registered", + "SENDER_ID_MISMATCH": "sender id does not match regisration token; code: authentication-error", + "QUOTA_EXCEEDED": "messaging service quota exceeded; code: message-rate-exceeded", + "APNS_AUTH_ERROR": "apns certificate or auth key was invalid; code: authentication-error", + "UNAVAILABLE": "backend servers are temporarily unavailable; code: server-unavailable", + "INTERNAL": "back servers encountered an unknown internl error; code: internal-error", + } + + iidErrorCodes = map[string]string{ + "INVALID_ARGUMENT": "request contains an invalid argument; code: invalid-argument", + "NOT_FOUND": "request contains an invalid argument; code: registration-token-not-registered", + "INTERNAL": "server encountered an internal error; code: internal-error", + "TOO_MANY_TOPICS": "client exceeded the number of allowed topics; code: too-many-topics", + } +) + +// Client is the interface for the Firebase Cloud Messaging (FCM) service. +type Client struct { + fcmEndpoint string // to enable testing against arbitrary endpoints + iidEndpoint string // to enable testing against arbitrary endpoints + client *internal.HTTPClient + project string + version string +} + +// Message to be sent via Firebase Cloud Messaging. +// +// Message contains payload data, recipient information and platform-specific configuration +// options. A Message must specify exactly one of Token, Topic or Condition fields. Apart from +// that a Message may specify any combination of Data, Notification, Android, Webpush and APNS +// fields. See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages for more +// details on how the backend FCM servers handle different message parameters. +type Message struct { + Data map[string]string `json:"data,omitempty"` + Notification *Notification `json:"notification,omitempty"` + Android *AndroidConfig `json:"android,omitempty"` + Webpush *WebpushConfig `json:"webpush,omitempty"` + APNS *APNSConfig `json:"apns,omitempty"` + Token string `json:"token,omitempty"` + Topic string `json:"-"` + Condition string `json:"condition,omitempty"` +} + +// MarshalJSON marshals a Message into JSON (for internal use only). +func (m *Message) MarshalJSON() ([]byte, error) { + // Create a new type to prevent infinite recursion. + type messageInternal Message + s := &struct { + BareTopic string `json:"topic,omitempty"` + *messageInternal + }{ + BareTopic: strings.TrimPrefix(m.Topic, "/topics/"), + messageInternal: (*messageInternal)(m), + } + return json.Marshal(s) +} + +// Notification is the basic notification template to use across all platforms. +type Notification struct { + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` +} + +// AndroidConfig contains messaging options specific to the Android platform. +type AndroidConfig struct { + CollapseKey string `json:"collapse_key,omitempty"` + Priority string `json:"priority,omitempty"` // one of "normal" or "high" + TTL *time.Duration `json:"-"` + RestrictedPackageName string `json:"restricted_package_name,omitempty"` + Data map[string]string `json:"data,omitempty"` // if specified, overrides the Data field on Message type + Notification *AndroidNotification `json:"notification,omitempty"` +} + +// MarshalJSON marshals an AndroidConfig into JSON (for internal use only). +func (a *AndroidConfig) MarshalJSON() ([]byte, error) { + var ttl string + if a.TTL != nil { + seconds := int64(*a.TTL / time.Second) + nanos := int64((*a.TTL - time.Duration(seconds)*time.Second) / time.Nanosecond) + if nanos > 0 { + ttl = fmt.Sprintf("%d.%09ds", seconds, nanos) + } else { + ttl = fmt.Sprintf("%ds", seconds) + } + } + + type androidInternal AndroidConfig + s := &struct { + TTL string `json:"ttl,omitempty"` + *androidInternal + }{ + TTL: ttl, + androidInternal: (*androidInternal)(a), + } + return json.Marshal(s) +} + +// AndroidNotification is a notification to send to Android devices. +type AndroidNotification struct { + Title string `json:"title,omitempty"` // if specified, overrides the Title field of the Notification type + Body string `json:"body,omitempty"` // if specified, overrides the Body field of the Notification type + Icon string `json:"icon,omitempty"` + Color string `json:"color,omitempty"` // notification color in #RRGGBB format + Sound string `json:"sound,omitempty"` + Tag string `json:"tag,omitempty"` + ClickAction string `json:"click_action,omitempty"` + BodyLocKey string `json:"body_loc_key,omitempty"` + BodyLocArgs []string `json:"body_loc_args,omitempty"` + TitleLocKey string `json:"title_loc_key,omitempty"` + TitleLocArgs []string `json:"title_loc_args,omitempty"` +} + +// WebpushConfig contains messaging options specific to the WebPush protocol. +// +// See https://tools.ietf.org/html/rfc8030#section-5 for additional details, and supported +// headers. +type WebpushConfig struct { + Headers map[string]string `json:"headers,omitempty"` + Data map[string]string `json:"data,omitempty"` + Notification *WebpushNotification `json:"notification,omitempty"` +} + +// WebpushNotification is a notification to send via WebPush protocol. +type WebpushNotification struct { + Title string `json:"title,omitempty"` // if specified, overrides the Title field of the Notification type + Body string `json:"body,omitempty"` // if specified, overrides the Body field of the Notification type + Icon string `json:"icon,omitempty"` +} + +// APNSConfig contains messaging options specific to the Apple Push Notification Service (APNS). +// +// See https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html +// for more details on supported headers and payload keys. +type APNSConfig struct { + Headers map[string]string `json:"headers,omitempty"` + Payload *APNSPayload `json:"payload,omitempty"` +} + +// APNSPayload is the payload that can be included in an APNS message. +// +// The payload mainly consists of the aps dictionary. Additionally it may contain arbitrary +// key-values pairs as custom data fields. +// +// See https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html +// for a full list of supported payload fields. +type APNSPayload struct { + Aps *Aps + CustomData map[string]interface{} +} + +// MarshalJSON marshals an APNSPayload into JSON (for internal use only). +func (p *APNSPayload) MarshalJSON() ([]byte, error) { + m := map[string]interface{}{"aps": p.Aps} + for k, v := range p.CustomData { + m[k] = v + } + return json.Marshal(m) +} + +// Aps represents the aps dictionary that may be included in an APNSPayload. +// +// Alert may be specified as a string (via the AlertString field), or as a struct (via the Alert +// field). +type Aps struct { + AlertString string `json:"-"` + Alert *ApsAlert `json:"-"` + Badge *int `json:"badge,omitempty"` + Sound string `json:"sound,omitempty"` + ContentAvailable bool `json:"-"` + Category string `json:"category,omitempty"` + ThreadID string `json:"thread-id,omitempty"` +} + +// MarshalJSON marshals an Aps into JSON (for internal use only). +func (a *Aps) MarshalJSON() ([]byte, error) { + type apsAlias Aps + s := &struct { + Alert interface{} `json:"alert,omitempty"` + ContentAvailable *int `json:"content-available,omitempty"` + *apsAlias + }{ + apsAlias: (*apsAlias)(a), + } + + if a.Alert != nil { + s.Alert = a.Alert + } else if a.AlertString != "" { + s.Alert = a.AlertString + } + if a.ContentAvailable { + one := 1 + s.ContentAvailable = &one + } + return json.Marshal(s) +} + +// ApsAlert is the alert payload that can be included in an Aps. +// +// See https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html +// for supported fields. +type ApsAlert struct { + Title string `json:"title,omitempty"` // if specified, overrides the Title field of the Notification type + Body string `json:"body,omitempty"` // if specified, overrides the Body field of the Notification type + LocKey string `json:"loc-key,omitempty"` + LocArgs []string `json:"loc-args,omitempty"` + TitleLocKey string `json:"title-loc-key,omitempty"` + TitleLocArgs []string `json:"title-loc-args,omitempty"` + ActionLocKey string `json:"action-loc-key,omitempty"` + LaunchImage string `json:"launch-image,omitempty"` +} + +// ErrorInfo is a topic management error. +type ErrorInfo struct { + Index int + Reason string +} + +// TopicManagementResponse is the result produced by topic management operations. +// +// TopicManagementResponse provides an overview of how many input tokens were successfully handled, +// and how many failed. In case of failures, the Errors list provides specific details concerning +// each error. +type TopicManagementResponse struct { + SuccessCount int + FailureCount int + Errors []*ErrorInfo +} + +func newTopicManagementResponse(resp *iidResponse) *TopicManagementResponse { + tmr := &TopicManagementResponse{} + for idx, res := range resp.Results { + if len(res) == 0 { + tmr.SuccessCount++ + } else { + tmr.FailureCount++ + code := res["error"].(string) + reason := iidErrorCodes[code] + if reason == "" { + reason = "unknown-error" + } + tmr.Errors = append(tmr.Errors, &ErrorInfo{ + Index: idx, + Reason: reason, + }) + } + } + return tmr +} + +// NewClient creates a new instance of the Firebase Cloud Messaging Client. +// +// This function can only be invoked from within the SDK. Client applications should access the +// the messaging service through firebase.App. +func NewClient(ctx context.Context, c *internal.MessagingConfig) (*Client, error) { + if c.ProjectID == "" { + return nil, errors.New("project ID is required to access Firebase Cloud Messaging client") + } + + hc, _, err := transport.NewHTTPClient(ctx, c.Opts...) + if err != nil { + return nil, err + } + + return &Client{ + fcmEndpoint: messagingEndpoint, + iidEndpoint: iidEndpoint, + client: &internal.HTTPClient{Client: hc}, + project: c.ProjectID, + version: "Go/Admin/" + c.Version, + }, nil +} + +// Send sends a Message to Firebase Cloud Messaging. +// +// The Message must specify exactly one of Token, Topic and Condition fields. FCM will +// customize the message for each target platform based on the arguments specified in the +// Message. +func (c *Client) Send(ctx context.Context, message *Message) (string, error) { + payload := &fcmRequest{ + Message: message, + } + return c.makeSendRequest(ctx, payload) +} + +// SendDryRun sends a Message to Firebase Cloud Messaging in the dry run (validation only) mode. +// +// This function does not actually deliver the message to target devices. Instead, it performs all +// the SDK-level and backend validations on the message, and emulates the send operation. +func (c *Client) SendDryRun(ctx context.Context, message *Message) (string, error) { + payload := &fcmRequest{ + ValidateOnly: true, + Message: message, + } + return c.makeSendRequest(ctx, payload) +} + +// SubscribeToTopic subscribes a list of registration tokens to a topic. +// +// The tokens list must not be empty, and have at most 1000 tokens. +func (c *Client) SubscribeToTopic(ctx context.Context, tokens []string, topic string) (*TopicManagementResponse, error) { + req := &iidRequest{ + Topic: topic, + Tokens: tokens, + op: iidSubscribe, + } + return c.makeTopicManagementRequest(ctx, req) +} + +// UnsubscribeFromTopic unsubscribes a list of registration tokens from a topic. +// +// The tokens list must not be empty, and have at most 1000 tokens. +func (c *Client) UnsubscribeFromTopic(ctx context.Context, tokens []string, topic string) (*TopicManagementResponse, error) { + req := &iidRequest{ + Topic: topic, + Tokens: tokens, + op: iidSubscribe, + } + return c.makeTopicManagementRequest(ctx, req) +} + +type fcmRequest struct { + ValidateOnly bool `json:"validate_only,omitempty"` + Message *Message `json:"message,omitempty"` +} + +type fcmResponse struct { + Name string `json:"name"` +} + +type fcmError struct { + Error struct { + Status string `json:"status"` + } `json:"error"` +} + +type iidRequest struct { + Topic string `json:"to"` + Tokens []string `json:"registration_tokens"` + op string +} + +type iidResponse struct { + Results []map[string]interface{} `json:"results"` +} + +type iidError struct { + Error string `json:"error"` +} + +func (c *Client) makeSendRequest(ctx context.Context, req *fcmRequest) (string, error) { + if err := validateMessage(req.Message); err != nil { + return "", err + } + + request := &internal.Request{ + Method: http.MethodPost, + URL: fmt.Sprintf("%s/projects/%s/messages:send", c.fcmEndpoint, c.project), + Body: internal.NewJSONEntity(req), + } + resp, err := c.client.Do(ctx, request) + if err != nil { + return "", err + } + + if resp.Status == http.StatusOK { + var result fcmResponse + err := json.Unmarshal(resp.Body, &result) + return result.Name, err + } + + var fe fcmError + json.Unmarshal(resp.Body, &fe) // ignore any json parse errors at this level + msg := fcmErrorCodes[fe.Error.Status] + if msg == "" { + msg = fmt.Sprintf("server responded with an unknown error; response: %s", string(resp.Body)) + } + return "", fmt.Errorf("http error status: %d; reason: %s", resp.Status, msg) +} + +func (c *Client) makeTopicManagementRequest(ctx context.Context, req *iidRequest) (*TopicManagementResponse, error) { + if len(req.Tokens) == 0 { + return nil, fmt.Errorf("no tokens specified") + } + if len(req.Tokens) > 1000 { + return nil, fmt.Errorf("tokens list must not contain more than 1000 items") + } + for _, token := range req.Tokens { + if token == "" { + return nil, fmt.Errorf("tokens list must not contain empty strings") + } + } + + if req.Topic == "" { + return nil, fmt.Errorf("topic name not specified") + } + if !topicNamePattern.MatchString(req.Topic) { + return nil, fmt.Errorf("invalid topic name: %q", req.Topic) + } + + if !strings.HasPrefix(req.Topic, "/topics/") { + req.Topic = "/topics/" + req.Topic + } + + request := &internal.Request{ + Method: http.MethodPost, + URL: fmt.Sprintf("%s/%s", c.iidEndpoint, req.op), + Body: internal.NewJSONEntity(req), + Opts: []internal.HTTPOption{internal.WithHeader("access_token_auth", "true")}, + } + resp, err := c.client.Do(ctx, request) + if err != nil { + return nil, err + } + + if resp.Status == http.StatusOK { + var result iidResponse + if err := json.Unmarshal(resp.Body, &result); err != nil { + return nil, err + } + return newTopicManagementResponse(&result), nil + } + + var ie iidError + json.Unmarshal(resp.Body, &ie) // ignore any json parse errors at this level + msg := iidErrorCodes[ie.Error] + if msg == "" { + msg = fmt.Sprintf("client encountered an unknown error; response: %s", string(resp.Body)) + } + return nil, fmt.Errorf("http error status: %d; reason: %s", resp.Status, msg) +} diff --git a/messaging/messaging_test.go b/messaging/messaging_test.go new file mode 100644 index 00000000..27808e84 --- /dev/null +++ b/messaging/messaging_test.go @@ -0,0 +1,870 @@ +// Copyright 2018 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package messaging + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "google.golang.org/api/option" + + "firebase.google.com/go/internal" +) + +const testMessageID = "projects/test-project/messages/msg_id" + +var ( + testMessagingConfig = &internal.MessagingConfig{ + ProjectID: "test-project", + Opts: []option.ClientOption{ + option.WithTokenSource(&internal.MockTokenSource{AccessToken: "test-token"}), + }, + } + + ttlWithNanos = time.Duration(1500) * time.Millisecond + ttl = time.Duration(10) * time.Second + invalidTTL = time.Duration(-10) * time.Second + + badge = 42 + badgeZero = 0 +) + +var validMessages = []struct { + name string + req *Message + want map[string]interface{} +}{ + { + name: "TokenOnly", + req: &Message{Token: "test-token"}, + want: map[string]interface{}{"token": "test-token"}, + }, + { + name: "TopicOnly", + req: &Message{Topic: "test-topic"}, + want: map[string]interface{}{"topic": "test-topic"}, + }, + { + name: "PrefixedTopicOnly", + req: &Message{Topic: "/topics/test-topic"}, + want: map[string]interface{}{"topic": "test-topic"}, + }, + { + name: "ConditionOnly", + req: &Message{Condition: "test-condition"}, + want: map[string]interface{}{"condition": "test-condition"}, + }, + { + name: "DataMessage", + req: &Message{ + Data: map[string]string{ + "k1": "v1", + "k2": "v2", + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "data": map[string]interface{}{ + "k1": "v1", + "k2": "v2", + }, + "topic": "test-topic", + }, + }, + { + name: "NotificationMessage", + req: &Message{ + Notification: &Notification{ + Title: "t", + Body: "b", + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "notification": map[string]interface{}{ + "title": "t", + "body": "b", + }, + "topic": "test-topic", + }, + }, + { + name: "AndroidDataMessage", + req: &Message{ + Android: &AndroidConfig{ + CollapseKey: "ck", + Data: map[string]string{ + "k1": "v1", + "k2": "v2", + }, + Priority: "normal", + TTL: &ttl, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "android": map[string]interface{}{ + "collapse_key": "ck", + "data": map[string]interface{}{ + "k1": "v1", + "k2": "v2", + }, + "priority": "normal", + "ttl": "10s", + }, + "topic": "test-topic", + }, + }, + { + name: "AndroidNotificationMessage", + req: &Message{ + Android: &AndroidConfig{ + RestrictedPackageName: "rpn", + Notification: &AndroidNotification{ + Title: "t", + Body: "b", + Color: "#112233", + Sound: "s", + TitleLocKey: "tlk", + TitleLocArgs: []string{"t1", "t2"}, + BodyLocKey: "blk", + BodyLocArgs: []string{"b1", "b2"}, + }, + TTL: &ttlWithNanos, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "android": map[string]interface{}{ + "restricted_package_name": "rpn", + "notification": map[string]interface{}{ + "title": "t", + "body": "b", + "color": "#112233", + "sound": "s", + "title_loc_key": "tlk", + "title_loc_args": []interface{}{"t1", "t2"}, + "body_loc_key": "blk", + "body_loc_args": []interface{}{"b1", "b2"}, + }, + "ttl": "1.500000000s", + }, + "topic": "test-topic", + }, + }, + { + name: "AndroidNoTTL", + req: &Message{ + Android: &AndroidConfig{ + Priority: "high", + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "android": map[string]interface{}{ + "priority": "high", + }, + "topic": "test-topic", + }, + }, + { + name: "WebpushMessage", + req: &Message{ + Webpush: &WebpushConfig{ + Headers: map[string]string{ + "h1": "v1", + "h2": "v2", + }, + Data: map[string]string{ + "k1": "v1", + "k2": "v2", + }, + Notification: &WebpushNotification{ + Title: "t", + Body: "b", + Icon: "i", + }, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "webpush": map[string]interface{}{ + "headers": map[string]interface{}{"h1": "v1", "h2": "v2"}, + "data": map[string]interface{}{"k1": "v1", "k2": "v2"}, + "notification": map[string]interface{}{"title": "t", "body": "b", "icon": "i"}, + }, + "topic": "test-topic", + }, + }, + { + name: "APNSHeadersOnly", + req: &Message{ + APNS: &APNSConfig{ + Headers: map[string]string{ + "h1": "v1", + "h2": "v2", + }, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "apns": map[string]interface{}{ + "headers": map[string]interface{}{"h1": "v1", "h2": "v2"}, + }, + "topic": "test-topic", + }, + }, + { + name: "APNSAlertString", + req: &Message{ + APNS: &APNSConfig{ + Headers: map[string]string{ + "h1": "v1", + "h2": "v2", + }, + Payload: &APNSPayload{ + Aps: &Aps{ + AlertString: "a", + Badge: &badge, + Category: "c", + Sound: "s", + ThreadID: "t", + ContentAvailable: true, + }, + CustomData: map[string]interface{}{ + "k1": "v1", + "k2": true, + }, + }, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "apns": map[string]interface{}{ + "headers": map[string]interface{}{"h1": "v1", "h2": "v2"}, + "payload": map[string]interface{}{ + "aps": map[string]interface{}{ + "alert": "a", + "badge": float64(badge), + "category": "c", + "sound": "s", + "thread-id": "t", + "content-available": float64(1), + }, + "k1": "v1", + "k2": true, + }, + }, + "topic": "test-topic", + }, + }, + { + name: "APNSBadgeZero", + req: &Message{ + APNS: &APNSConfig{ + Payload: &APNSPayload{ + Aps: &Aps{ + Badge: &badgeZero, + Category: "c", + Sound: "s", + ThreadID: "t", + ContentAvailable: true, + }, + }, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "apns": map[string]interface{}{ + "payload": map[string]interface{}{ + "aps": map[string]interface{}{ + "badge": float64(badgeZero), + "category": "c", + "sound": "s", + "thread-id": "t", + "content-available": float64(1), + }, + }, + }, + "topic": "test-topic", + }, + }, + { + name: "APNSAlertObject", + req: &Message{ + APNS: &APNSConfig{ + Payload: &APNSPayload{ + Aps: &Aps{ + Alert: &ApsAlert{ + Title: "t", + Body: "b", + TitleLocKey: "tlk", + TitleLocArgs: []string{"t1", "t2"}, + LocKey: "blk", + LocArgs: []string{"b1", "b2"}, + ActionLocKey: "alk", + LaunchImage: "li", + }, + }, + }, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "apns": map[string]interface{}{ + "payload": map[string]interface{}{ + "aps": map[string]interface{}{ + "alert": map[string]interface{}{ + "title": "t", + "body": "b", + "title-loc-key": "tlk", + "title-loc-args": []interface{}{"t1", "t2"}, + "loc-key": "blk", + "loc-args": []interface{}{"b1", "b2"}, + "action-loc-key": "alk", + "launch-image": "li", + }, + }, + }, + }, + "topic": "test-topic", + }, + }, +} + +var invalidMessages = []struct { + name string + req *Message + want string +}{ + { + name: "NilMessage", + req: nil, + want: "message must not be nil", + }, + { + name: "NoTargets", + req: &Message{}, + want: "exactly one of token, topic or condition must be specified", + }, + { + name: "MultipleTargets", + req: &Message{ + Token: "token", + Topic: "topic", + }, + want: "exactly one of token, topic or condition must be specified", + }, + { + name: "InvalidPrefixedTopicName", + req: &Message{ + Topic: "/topics/", + }, + want: "malformed topic name", + }, + { + name: "InvalidTopicName", + req: &Message{ + Topic: "foo*bar", + }, + want: "malformed topic name", + }, + { + name: "InvalidAndroidTTL", + req: &Message{ + Android: &AndroidConfig{ + TTL: &invalidTTL, + }, + Topic: "topic", + }, + want: "ttl duration must not be negative", + }, + { + name: "InvalidAndroidPriority", + req: &Message{ + Android: &AndroidConfig{ + Priority: "not normal", + }, + Topic: "topic", + }, + want: "priority must be 'normal' or 'high'", + }, + { + name: "InvalidAndroidColor1", + req: &Message{ + Android: &AndroidConfig{ + Notification: &AndroidNotification{ + Color: "112233", + }, + }, + Topic: "topic", + }, + want: "color must be in the #RRGGBB form", + }, + { + name: "InvalidAndroidColor2", + req: &Message{ + Android: &AndroidConfig{ + Notification: &AndroidNotification{ + Color: "#112233X", + }, + }, + Topic: "topic", + }, + want: "color must be in the #RRGGBB form", + }, + { + name: "InvalidAndroidTitleLocArgs", + req: &Message{ + Android: &AndroidConfig{ + Notification: &AndroidNotification{ + TitleLocArgs: []string{"a1"}, + }, + }, + Topic: "topic", + }, + want: "titleLocKey is required when specifying titleLocArgs", + }, + { + name: "InvalidAndroidBodyLocArgs", + req: &Message{ + Android: &AndroidConfig{ + Notification: &AndroidNotification{ + BodyLocArgs: []string{"a1"}, + }, + }, + Topic: "topic", + }, + want: "bodyLocKey is required when specifying bodyLocArgs", + }, + { + name: "APNSMultipleAlerts", + req: &Message{ + APNS: &APNSConfig{ + Payload: &APNSPayload{ + Aps: &Aps{ + Alert: &ApsAlert{}, + AlertString: "alert", + }, + }, + }, + Topic: "topic", + }, + want: "multiple alert specifications", + }, + { + name: "InvalidAPNSTitleLocArgs", + req: &Message{ + APNS: &APNSConfig{ + Payload: &APNSPayload{ + Aps: &Aps{ + Alert: &ApsAlert{ + TitleLocArgs: []string{"a1"}, + }, + }, + }, + }, + Topic: "topic", + }, + want: "titleLocKey is required when specifying titleLocArgs", + }, + { + name: "InvalidAPNSLocArgs", + req: &Message{ + APNS: &APNSConfig{ + Payload: &APNSPayload{ + Aps: &Aps{ + Alert: &ApsAlert{ + LocArgs: []string{"a1"}, + }, + }, + }, + }, + Topic: "topic", + }, + want: "locKey is required when specifying locArgs", + }, +} + +var invalidTopicMgtArgs = []struct { + name string + tokens []string + topic string + want string +}{ + { + name: "NoTokensAndTopic", + want: "no tokens specified", + }, + { + name: "NoTopic", + tokens: []string{"token1"}, + want: "topic name not specified", + }, + { + name: "InvalidTopicName", + tokens: []string{"token1"}, + topic: "foo*bar", + want: "invalid topic name: \"foo*bar\"", + }, + { + name: "TooManyTokens", + tokens: strings.Split("a"+strings.Repeat(",a", 1000), ","), + topic: "topic", + want: "tokens list must not contain more than 1000 items", + }, + { + name: "EmptyToken", + tokens: []string{"foo", ""}, + topic: "topic", + want: "tokens list must not contain empty strings", + }, +} + +func TestNoProjectID(t *testing.T) { + client, err := NewClient(context.Background(), &internal.MessagingConfig{}) + if client != nil || err == nil { + t.Errorf("NewClient() = (%v, %v); want = (nil, error)", client, err) + } +} + +func TestSend(t *testing.T) { + var tr *http.Request + var b []byte + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tr = r + b, _ = ioutil.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{ \"name\":\"" + testMessageID + "\" }")) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.fcmEndpoint = ts.URL + + for _, tc := range validMessages { + t.Run(tc.name, func(t *testing.T) { + name, err := client.Send(ctx, tc.req) + if name != testMessageID || err != nil { + t.Errorf("Send() = (%q, %v); want = (%q, nil)", name, err, testMessageID) + } + checkFCMRequest(t, b, tr, tc.want, false) + }) + } +} + +func TestSendDryRun(t *testing.T) { + var tr *http.Request + var b []byte + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tr = r + b, _ = ioutil.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{ \"name\":\"" + testMessageID + "\" }")) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.fcmEndpoint = ts.URL + + for _, tc := range validMessages { + t.Run(tc.name, func(t *testing.T) { + name, err := client.SendDryRun(ctx, tc.req) + if name != testMessageID || err != nil { + t.Errorf("SendDryRun() = (%q, %v); want = (%q, nil)", name, err, testMessageID) + } + checkFCMRequest(t, b, tr, tc.want, true) + }) + } +} + +func TestSendError(t *testing.T) { + var resp string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(resp)) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.fcmEndpoint = ts.URL + + cases := []struct { + resp string + want string + }{ + { + resp: "{}", + want: "http error status: 500; reason: server responded with an unknown error; response: {}", + }, + { + resp: "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}", + want: "http error status: 500; reason: request contains an invalid argument; code: invalid-argument", + }, + { + resp: "not json", + want: "http error status: 500; reason: server responded with an unknown error; response: not json", + }, + } + for _, tc := range cases { + resp = tc.resp + name, err := client.Send(ctx, &Message{Topic: "topic"}) + if err == nil || err.Error() != tc.want { + t.Errorf("Send() = (%q, %v); want = (%q, %q)", name, err, "", tc.want) + } + } +} + +func TestInvalidMessage(t *testing.T) { + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + for _, tc := range invalidMessages { + t.Run(tc.name, func(t *testing.T) { + name, err := client.Send(ctx, tc.req) + if err == nil || err.Error() != tc.want { + t.Errorf("Send() = (%q, %v); want = (%q, %q)", name, err, "", tc.want) + } + }) + } +} + +func TestSubscribe(t *testing.T) { + var tr *http.Request + var b []byte + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tr = r + b, _ = ioutil.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{\"results\": [{}, {\"error\": \"error_reason\"}]}")) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.iidEndpoint = ts.URL + + resp, err := client.SubscribeToTopic(ctx, []string{"id1", "id2"}, "test-topic") + if err != nil { + t.Fatal(err) + } + checkIIDRequest(t, b, tr, iidSubscribe) + checkTopicMgtResponse(t, resp) +} + +func TestInvalidSubscribe(t *testing.T) { + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + for _, tc := range invalidTopicMgtArgs { + t.Run(tc.name, func(t *testing.T) { + name, err := client.SubscribeToTopic(ctx, tc.tokens, tc.topic) + if err == nil || err.Error() != tc.want { + t.Errorf("SubscribeToTopic() = (%q, %v); want = (%q, %q)", name, err, "", tc.want) + } + }) + } +} + +func TestUnsubscribe(t *testing.T) { + var tr *http.Request + var b []byte + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tr = r + b, _ = ioutil.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{\"results\": [{}, {\"error\": \"error_reason\"}]}")) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.iidEndpoint = ts.URL + + resp, err := client.UnsubscribeFromTopic(ctx, []string{"id1", "id2"}, "test-topic") + if err != nil { + t.Fatal(err) + } + checkIIDRequest(t, b, tr, iidSubscribe) + checkTopicMgtResponse(t, resp) +} + +func TestInvalidUnsubscribe(t *testing.T) { + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + for _, tc := range invalidTopicMgtArgs { + t.Run(tc.name, func(t *testing.T) { + name, err := client.UnsubscribeFromTopic(ctx, tc.tokens, tc.topic) + if err == nil || err.Error() != tc.want { + t.Errorf("UnsubscribeFromTopic() = (%q, %v); want = (%q, %q)", name, err, "", tc.want) + } + }) + } +} + +func TestTopicManagementError(t *testing.T) { + var resp string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(resp)) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.iidEndpoint = ts.URL + + cases := []struct { + resp string + want string + }{ + { + resp: "{}", + want: "http error status: 500; reason: client encountered an unknown error; response: {}", + }, + { + resp: "{\"error\": \"INVALID_ARGUMENT\"}", + want: "http error status: 500; reason: request contains an invalid argument; code: invalid-argument", + }, + { + resp: "not json", + want: "http error status: 500; reason: client encountered an unknown error; response: not json", + }, + } + for _, tc := range cases { + resp = tc.resp + tmr, err := client.SubscribeToTopic(ctx, []string{"id1"}, "topic") + if err == nil || err.Error() != tc.want { + t.Errorf("SubscribeToTopic() = (%q, %v); want = (%q, %q)", tmr, err, "", tc.want) + } + } + for _, tc := range cases { + resp = tc.resp + tmr, err := client.UnsubscribeFromTopic(ctx, []string{"id1"}, "topic") + if err == nil || err.Error() != tc.want { + t.Errorf("UnsubscribeFromTopic() = (%q, %v); want = (%q, %q)", tmr, err, "", tc.want) + } + } +} + +func checkFCMRequest(t *testing.T, b []byte, tr *http.Request, want map[string]interface{}, dryRun bool) { + var parsed map[string]interface{} + if err := json.Unmarshal(b, &parsed); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(parsed["message"], want) { + t.Errorf("Body = %#v; want = %#v", parsed["message"], want) + } + + validate, ok := parsed["validate_only"] + if dryRun { + if !ok || validate != true { + t.Errorf("ValidateOnly = %v; want = true", validate) + } + } else if ok { + t.Errorf("ValidateOnly = %v; want none", validate) + } + + if tr.Method != http.MethodPost { + t.Errorf("Method = %q; want = %q", tr.Method, http.MethodPost) + } + if tr.URL.Path != "/projects/test-project/messages:send" { + t.Errorf("Path = %q; want = %q", tr.URL.Path, "/projects/test-project/messages:send") + } + if h := tr.Header.Get("Authorization"); h != "Bearer test-token" { + t.Errorf("Authorization = %q; want = %q", h, "Bearer test-token") + } +} + +func checkIIDRequest(t *testing.T, b []byte, tr *http.Request, op string) { + var parsed map[string]interface{} + if err := json.Unmarshal(b, &parsed); err != nil { + t.Fatal(err) + } + want := map[string]interface{}{ + "to": "/topics/test-topic", + "registration_tokens": []interface{}{"id1", "id2"}, + } + if !reflect.DeepEqual(parsed, want) { + t.Errorf("Body = %#v; want = %#v", parsed, want) + } + + if tr.Method != http.MethodPost { + t.Errorf("Method = %q; want = %q", tr.Method, http.MethodPost) + } + wantOp := "/" + op + if tr.URL.Path != wantOp { + t.Errorf("Path = %q; want = %q", tr.URL.Path, wantOp) + } + if h := tr.Header.Get("Authorization"); h != "Bearer test-token" { + t.Errorf("Authorization = %q; want = %q", h, "Bearer test-token") + } +} + +func checkTopicMgtResponse(t *testing.T, resp *TopicManagementResponse) { + if resp.SuccessCount != 1 { + t.Errorf("SuccessCount = %d; want = %d", resp.SuccessCount, 1) + } + if resp.FailureCount != 1 { + t.Errorf("FailureCount = %d; want = %d", resp.FailureCount, 1) + } + if len(resp.Errors) != 1 { + t.Fatalf("Errors = %d; want = %d", len(resp.Errors), 1) + } + e := resp.Errors[0] + if e.Index != 1 { + t.Errorf("ErrorInfo.Index = %d; want = %d", e.Index, 1) + } + if e.Reason != "unknown-error" { + t.Errorf("ErrorInfo.Reason = %s; want = %s", e.Reason, "unknown-error") + } +} diff --git a/messaging/messaging_utils.go b/messaging/messaging_utils.go new file mode 100644 index 00000000..ffd4df95 --- /dev/null +++ b/messaging/messaging_utils.go @@ -0,0 +1,131 @@ +// Copyright 2018 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package messaging + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + bareTopicNamePattern = regexp.MustCompile("^[a-zA-Z0-9-_.~%]+$") + colorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$") +) + +func validateMessage(message *Message) error { + if message == nil { + return fmt.Errorf("message must not be nil") + } + + targets := countNonEmpty(message.Token, message.Condition, message.Topic) + if targets != 1 { + return fmt.Errorf("exactly one of token, topic or condition must be specified") + } + + // validate topic + if message.Topic != "" { + bt := strings.TrimPrefix(message.Topic, "/topics/") + if !bareTopicNamePattern.MatchString(bt) { + return fmt.Errorf("malformed topic name") + } + } + + // validate AndroidConfig + if err := validateAndroidConfig(message.Android); err != nil { + return err + } + + // validate APNSConfig + return validateAPNSConfig(message.APNS) +} + +func validateAndroidConfig(config *AndroidConfig) error { + if config == nil { + return nil + } + + if config.TTL != nil && config.TTL.Seconds() < 0 { + return fmt.Errorf("ttl duration must not be negative") + } + if config.Priority != "" && config.Priority != "normal" && config.Priority != "high" { + return fmt.Errorf("priority must be 'normal' or 'high'") + } + // validate AndroidNotification + return validateAndroidNotification(config.Notification) +} + +func validateAndroidNotification(notification *AndroidNotification) error { + if notification == nil { + return nil + } + if notification.Color != "" && !colorPattern.MatchString(notification.Color) { + return fmt.Errorf("color must be in the #RRGGBB form") + } + if len(notification.TitleLocArgs) > 0 && notification.TitleLocKey == "" { + return fmt.Errorf("titleLocKey is required when specifying titleLocArgs") + } + if len(notification.BodyLocArgs) > 0 && notification.BodyLocKey == "" { + return fmt.Errorf("bodyLocKey is required when specifying bodyLocArgs") + } + return nil +} + +func validateAPNSConfig(config *APNSConfig) error { + if config != nil { + return validateAPNSPayload(config.Payload) + } + return nil +} + +func validateAPNSPayload(payload *APNSPayload) error { + if payload != nil { + return validateAps(payload.Aps) + } + return nil +} + +func validateAps(aps *Aps) error { + if aps != nil { + if aps.Alert != nil && aps.AlertString != "" { + return fmt.Errorf("multiple alert specifications") + } + return validateApsAlert(aps.Alert) + } + return nil +} + +func validateApsAlert(alert *ApsAlert) error { + if alert == nil { + return nil + } + if len(alert.TitleLocArgs) > 0 && alert.TitleLocKey == "" { + return fmt.Errorf("titleLocKey is required when specifying titleLocArgs") + } + if len(alert.LocArgs) > 0 && alert.LocKey == "" { + return fmt.Errorf("locKey is required when specifying locArgs") + } + return nil +} + +func countNonEmpty(strings ...string) int { + count := 0 + for _, s := range strings { + if s != "" { + count++ + } + } + return count +} From a053b99dd1dbc4165080ec00211d20aea6161f9e Mon Sep 17 00:00:00 2001 From: Hiranya Jayathilaka Date: Wed, 14 Feb 2018 13:24:17 -0800 Subject: [PATCH 9/9] Bumped version to 2.5.0 (#90) --- CHANGELOG.md | 6 +++++- firebase.go | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48d813f0..3060d302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,14 @@ # Unreleased +- + +# v2.5.0 + - [changed] Import context from `golang.org/x/net` for 1.6 compatibility ### Cloud Messaging -- [feature] Added the `messaging` package for sending Firebase notifications +- [added] Added the `messaging` package for sending Firebase notifications and managing topic subscriptions. ### Authentication diff --git a/firebase.go b/firebase.go index 9ed07125..ed09ac6d 100644 --- a/firebase.go +++ b/firebase.go @@ -48,7 +48,7 @@ var firebaseScopes = []string{ } // Version of the Firebase Go Admin SDK. -const Version = "2.4.0" +const Version = "2.5.0" // firebaseEnvName is the name of the environment variable with the Config. const firebaseEnvName = "FIREBASE_CONFIG"