diff --git a/auth/auth.go b/auth/auth.go index f2e237f5..5b3f7dbc 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -16,15 +16,14 @@ package auth import ( + "crypto/rsa" + "crypto/x509" "encoding/json" "encoding/pem" "errors" "fmt" "strings" - "crypto/rsa" - "crypto/x509" - "firebase.google.com/go/internal" ) @@ -62,8 +61,12 @@ type Token struct { type Client struct { ks keySource projectID string - email string - pk *rsa.PrivateKey + snr signer +} + +type signer interface { + Email() (string, error) + Sign(b []byte) ([]byte, error) } // NewClient creates a new instance of the Firebase Auth Client. @@ -71,36 +74,47 @@ type Client struct { // This function can only be invoked from within the SDK. Client applications should access the // the Auth service through firebase.App. func NewClient(c *internal.AuthConfig) (*Client, error) { + var ( + err error + email string + pk *rsa.PrivateKey + ) + if c.Creds != nil && len(c.Creds.JSON) > 0 { + var svcAcct struct { + ClientEmail string `json:"client_email"` + PrivateKey string `json:"private_key"` + } + if err := json.Unmarshal(c.Creds.JSON, &svcAcct); err != nil { + return nil, err + } + if svcAcct.PrivateKey != "" { + pk, err = parseKey(svcAcct.PrivateKey) + if err != nil { + return nil, err + } + } + email = svcAcct.ClientEmail + } + var snr signer + if email != "" && pk != nil { + snr = serviceAcctSigner{email: email, pk: pk} + } else { + snr, err = newSigner(c.Ctx) + if err != nil { + return nil, err + } + } + ks, err := newHTTPKeySource(c.Ctx, googleCertURL, c.Opts...) if err != nil { return nil, err } - client := &Client{ + return &Client{ ks: ks, projectID: c.ProjectID, - } - if c.Creds == nil || len(c.Creds.JSON) == 0 { - return client, nil - } - - var svcAcct struct { - ClientEmail string `json:"client_email"` - PrivateKey string `json:"private_key"` - } - if err := json.Unmarshal(c.Creds.JSON, &svcAcct); err != nil { - return nil, err - } - - if svcAcct.PrivateKey != "" { - pk, err := parseKey(svcAcct.PrivateKey) - if err != nil { - return nil, err - } - client.pk = pk - } - client.email = svcAcct.ClientEmail - return client, nil + snr: snr, + }, nil } // CustomToken creates a signed custom authentication token with the specified user ID. The resulting @@ -114,11 +128,9 @@ func (c *Client) CustomToken(uid string) (string, error) { // CustomTokenWithClaims is similar to CustomToken, but in addition to the user ID, it also encodes // all the key-value pairs in the provided map as claims in the resulting JWT. func (c *Client) CustomTokenWithClaims(uid string, devClaims map[string]interface{}) (string, error) { - if c.email == "" { - return "", errors.New("service account email not available") - } - if c.pk == nil { - return "", errors.New("private key not available") + iss, err := c.snr.Email() + if err != nil { + return "", err } if len(uid) == 0 || len(uid) > 128 { @@ -139,15 +151,15 @@ func (c *Client) CustomTokenWithClaims(uid string, devClaims map[string]interfac now := clk.Now().Unix() payload := &customToken{ - Iss: c.email, - Sub: c.email, + Iss: iss, + Sub: iss, Aud: firebaseAudience, UID: uid, Iat: now, Exp: now + tokenExpSeconds, Claims: devClaims, } - return encodeToken(defaultHeader(), payload, c.pk) + return encodeToken(c.snr, defaultHeader(), payload) } // VerifyIDToken verifies the signature and payload of the provided ID token. diff --git a/auth/auth_appengine.go b/auth/auth_appengine.go new file mode 100644 index 00000000..351f61c1 --- /dev/null +++ b/auth/auth_appengine.go @@ -0,0 +1,40 @@ +// +build appengine + +// Copyright 2017 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 auth + +import ( + "golang.org/x/net/context" + + "google.golang.org/appengine" +) + +type aeSigner struct { + ctx context.Context +} + +func newSigner(ctx context.Context) (signer, error) { + return aeSigner{ctx}, nil +} + +func (s aeSigner) Email() (string, error) { + return appengine.ServiceAccount(s.ctx) +} + +func (s aeSigner) Sign(ss []byte) ([]byte, error) { + _, sig, err := appengine.SignBytes(s.ctx, ss) + return sig, err +} diff --git a/auth/auth_std.go b/auth/auth_std.go new file mode 100644 index 00000000..f593a7cc --- /dev/null +++ b/auth/auth_std.go @@ -0,0 +1,23 @@ +// +build !appengine + +// Copyright 2017 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 auth + +import "context" + +func newSigner(ctx context.Context) (signer, error) { + return serviceAcctSigner{}, nil +} diff --git a/auth/auth_test.go b/auth/auth_test.go index e29bb7f5..489c323d 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -16,6 +16,7 @@ package auth import ( "errors" + "io/ioutil" "log" "os" "strings" @@ -27,90 +28,54 @@ import ( "google.golang.org/api/option" "google.golang.org/api/transport" + "google.golang.org/appengine" + "google.golang.org/appengine/aetest" "firebase.google.com/go/internal" ) -var creds *google.DefaultCredentials var client *Client var testIDToken string -func verifyCustomToken(t *testing.T, token string, expected map[string]interface{}) { - h := &jwtHeader{} - p := &customToken{} - if err := decodeToken(token, client.ks, h, p); err != nil { - t.Fatal(err) - } - - if h.Algorithm != "RS256" { - t.Errorf("Algorithm: %q; want: 'RS256'", h.Algorithm) - } else if h.Type != "JWT" { - t.Errorf("Type: %q; want: 'JWT'", h.Type) - } else if p.Aud != firebaseAudience { - t.Errorf("Audience: %q; want: %q", p.Aud, firebaseAudience) - } - - for k, v := range expected { - if p.Claims[k] != v { - t.Errorf("Claim[%q]: %v; want: %v", k, p.Claims[k], v) +func TestMain(m *testing.M) { + var ( + err error + ks keySource + ctx context.Context + creds *google.DefaultCredentials + ) + + if appengine.IsDevAppServer() { + aectx, aedone, err := aetest.NewContext() + if err != nil { + log.Fatalln(err) } - } -} - -func getIDToken(p mockIDTokenPayload) string { - return getIDTokenWithKid("mock-key-id-1", p) -} - -func getIDTokenWithKid(kid string, p mockIDTokenPayload) string { - pCopy := mockIDTokenPayload{ - "aud": client.projectID, - "iss": "https://securetoken.google.com/" + client.projectID, - "iat": time.Now().Unix() - 100, - "exp": time.Now().Unix() + 3600, - "sub": "1234567890", - "admin": true, - } - for k, v := range p { - pCopy[k] = v - } - h := defaultHeader() - h.KeyID = kid - token, _ := encodeToken(h, pCopy, client.pk) - return token -} - -type mockIDTokenPayload map[string]interface{} - -func (p mockIDTokenPayload) decode(s string) error { - return decode(s, &p) -} + ctx = aectx + defer aedone() -type mockKeySource struct { - keys []*publicKey - err error -} - -func (t *mockKeySource) Keys() ([]*publicKey, error) { - return t.keys, t.err -} + ks, err = newAEKeySource(ctx) + if err != nil { + log.Fatalln(err) + } + } else { + opt := option.WithCredentialsFile("../testdata/service_account.json") + creds, err = transport.Creds(context.Background(), opt) + if err != nil { + log.Fatalln(err) + } -func TestMain(m *testing.M) { - var err error - opt := option.WithCredentialsFile("../testdata/service_account.json") - creds, err = transport.Creds(context.Background(), opt) - if err != nil { - log.Fatalln(err) + ks = &fileKeySource{FilePath: "../testdata/public_certs.json"} } client, err = NewClient(&internal.AuthConfig{ - Ctx: context.Background(), + Ctx: ctx, Creds: creds, ProjectID: "mock-project-id", }) if err != nil { log.Fatalln(err) } - client.ks = &fileKeySource{FilePath: "../testdata/public_certs.json"} + client.ks = ks testIDToken = getIDToken(nil) os.Exit(m.Run()) @@ -228,7 +193,7 @@ func TestVerifyIDTokenError(t *testing.T) { } func TestNoProjectID(t *testing.T) { - c, err := NewClient(&internal.AuthConfig{Ctx: context.Background(), Creds: creds}) + c, err := NewClient(&internal.AuthConfig{Ctx: context.Background()}) if err != nil { t.Fatal(err) } @@ -259,3 +224,123 @@ func TestCertificateRequestError(t *testing.T) { t.Error("VeridyIDToken() = nil; want error") } } + +func verifyCustomToken(t *testing.T, token string, expected map[string]interface{}) { + h := &jwtHeader{} + p := &customToken{} + if err := decodeToken(token, client.ks, h, p); err != nil { + t.Fatal(err) + } + + email, err := client.snr.Email() + if err != nil { + t.Fatal(err) + } + + if h.Algorithm != "RS256" { + t.Errorf("Algorithm: %q; want: 'RS256'", h.Algorithm) + } else if h.Type != "JWT" { + t.Errorf("Type: %q; want: 'JWT'", h.Type) + } else if p.Aud != firebaseAudience { + t.Errorf("Audience: %q; want: %q", p.Aud, firebaseAudience) + } else if p.Iss != email { + t.Errorf("Issuer: %q; want: %q", p.Iss, email) + } else if p.Sub != email { + t.Errorf("Subject: %q; want: %q", p.Sub, email) + } + + for k, v := range expected { + if p.Claims[k] != v { + t.Errorf("Claim[%q]: %v; want: %v", k, p.Claims[k], v) + } + } +} + +func getIDToken(p mockIDTokenPayload) string { + return getIDTokenWithKid("mock-key-id-1", p) +} + +func getIDTokenWithKid(kid string, p mockIDTokenPayload) string { + pCopy := mockIDTokenPayload{ + "aud": client.projectID, + "iss": "https://securetoken.google.com/" + client.projectID, + "iat": time.Now().Unix() - 100, + "exp": time.Now().Unix() + 3600, + "sub": "1234567890", + "admin": true, + } + for k, v := range p { + pCopy[k] = v + } + h := defaultHeader() + h.KeyID = kid + token, err := encodeToken(client.snr, h, pCopy) + if err != nil { + log.Fatalln(err) + } + return token +} + +type mockIDTokenPayload map[string]interface{} + +func (p mockIDTokenPayload) decode(s string) error { + return decode(s, &p) +} + +// mockKeySource provides access to a set of in-memory public keys. +type mockKeySource struct { + keys []*publicKey + err error +} + +func (t *mockKeySource) Keys() ([]*publicKey, error) { + return t.keys, t.err +} + +// fileKeySource loads a set of public keys from the local file system. +type fileKeySource struct { + FilePath string + CachedKeys []*publicKey +} + +func (f *fileKeySource) Keys() ([]*publicKey, error) { + if f.CachedKeys == nil { + certs, err := ioutil.ReadFile(f.FilePath) + if err != nil { + return nil, err + } + f.CachedKeys, err = parsePublicKeys(certs) + if err != nil { + return nil, err + } + } + return f.CachedKeys, nil +} + +// aeKeySource provides access to the public keys associated with App Engine apps. This +// is used in tests to verify custom tokens and mock ID tokens when they are signed with +// App Engine private keys. +type aeKeySource struct { + keys []*publicKey +} + +func newAEKeySource(ctx context.Context) (keySource, error) { + certs, err := appengine.PublicCertificates(ctx) + if err != nil { + return nil, err + } + keys := make([]*publicKey, len(certs)) + for i, cert := range certs { + pk, err := parsePublicKey("mock-key-id-1", cert.Data) + if err != nil { + return nil, err + } + keys[i] = pk + } + return aeKeySource{keys}, nil +} + +// Keys returns the RSA Public Keys managed by App Engine. +func (k aeKeySource) Keys() ([]*publicKey, error) { + return k.keys, nil +} diff --git a/auth/crypto.go b/auth/crypto.go index 97fc39d1..ec7bf713 100644 --- a/auth/crypto.go +++ b/auth/crypto.go @@ -16,6 +16,7 @@ package auth import ( "crypto" + "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" @@ -36,11 +37,13 @@ import ( "google.golang.org/api/transport" ) +// publicKey represents a parsed RSA public key along with its unique key ID. type publicKey struct { Kid string Key *rsa.PublicKey } +// clock is used to query the current local time. type clock interface { Now() time.Time } @@ -59,6 +62,8 @@ func (m *mockClock) Now() time.Time { return m.now } +// keySource is used to obtain a set of public keys, which can be used to verify cryptographic +// signatures. type keySource interface { Keys() ([]*publicKey, error) } @@ -141,25 +146,6 @@ func (k *httpKeySource) refreshKeys() error { return nil } -type fileKeySource struct { - FilePath string - CachedKeys []*publicKey -} - -func (f *fileKeySource) Keys() ([]*publicKey, error) { - if f.CachedKeys == nil { - certs, err := ioutil.ReadFile(f.FilePath) - if err != nil { - return nil, err - } - f.CachedKeys, err = parsePublicKeys(certs) - if err != nil { - return nil, err - } - } - return f.CachedKeys, nil -} - func findMaxAge(resp *http.Response) (*time.Duration, error) { cc := resp.Header.Get("cache-control") for _, value := range strings.Split(cc, ", ") { @@ -189,20 +175,28 @@ func parsePublicKeys(keys []byte) ([]*publicKey, error) { var result []*publicKey for kid, key := range m { - block, _ := pem.Decode([]byte(key)) - cert, err := x509.ParseCertificate(block.Bytes) + pubKey, err := parsePublicKey(kid, []byte(key)) if err != nil { return nil, err } - pk, ok := cert.PublicKey.(*rsa.PublicKey) - if !ok { - return nil, errors.New("Certificate is not a RSA key") - } - result = append(result, &publicKey{kid, pk}) + result = append(result, pubKey) } return result, nil } +func parsePublicKey(kid string, key []byte) (*publicKey, error) { + block, _ := pem.Decode(key) + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + pk, ok := cert.PublicKey.(*rsa.PublicKey) + if !ok { + return nil, errors.New("Certificate is not a RSA key") + } + return &publicKey{kid, pk}, nil +} + func verifySignature(parts []string, k *publicKey) error { content := parts[0] + "." + parts[1] signature, err := base64.RawURLEncoding.DecodeString(parts[2]) @@ -214,3 +208,24 @@ func verifySignature(parts []string, k *publicKey) error { h.Write([]byte(content)) return rsa.VerifyPKCS1v15(k.Key, crypto.SHA256, h.Sum(nil), []byte(signature)) } + +type serviceAcctSigner struct { + email string + pk *rsa.PrivateKey +} + +func (s serviceAcctSigner) Email() (string, error) { + if s.email == "" { + return "", errors.New("service account email not available") + } + return s.email, nil +} + +func (s serviceAcctSigner) Sign(ss []byte) ([]byte, error) { + if s.pk == nil { + return nil, errors.New("private key not available") + } + hash := sha256.New() + hash.Write([]byte(ss)) + return rsa.SignPKCS1v15(rand.Reader, s.pk, crypto.SHA256, hash.Sum(nil)) +} diff --git a/auth/jwt.go b/auth/jwt.go index 51d90fb4..4c6f0414 100644 --- a/auth/jwt.go +++ b/auth/jwt.go @@ -16,10 +16,6 @@ package auth import ( "bytes" - "crypto" - "crypto/rand" - "crypto/rsa" - "crypto/sha256" "encoding/base64" "encoding/json" "errors" @@ -90,7 +86,7 @@ func decode(s string, i interface{}) error { return nil } -func encodeToken(h jwtHeader, p jwtPayload, pk *rsa.PrivateKey) (string, error) { +func encodeToken(s signer, h jwtHeader, p jwtPayload) (string, error) { header, err := encode(h) if err != nil { return "", err @@ -101,9 +97,7 @@ func encodeToken(h jwtHeader, p jwtPayload, pk *rsa.PrivateKey) (string, error) } ss := fmt.Sprintf("%s.%s", header, payload) - hash := sha256.New() - hash.Write([]byte(ss)) - sig, err := rsa.SignPKCS1v15(rand.Reader, pk, crypto.SHA256, hash.Sum(nil)) + sig, err := s.Sign([]byte(ss)) if err != nil { return "", err } diff --git a/firebase.go b/firebase.go index 44c675f0..a7027db2 100644 --- a/firebase.go +++ b/firebase.go @@ -35,7 +35,7 @@ var firebaseScopes = []string{ } // Version of the Firebase Go Admin SDK. -const Version = "1.0.1" +const Version = "1.0.2" // An App holds configuration and state common to all Firebase services that are exposed from the SDK. type App struct { diff --git a/integration/auth_test.go b/integration/auth/auth_test.go similarity index 96% rename from integration/auth_test.go rename to integration/auth/auth_test.go index 37bf07c6..0ec6251c 100644 --- a/integration/auth_test.go +++ b/integration/auth/auth_test.go @@ -12,7 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -package integration +// Package auth contains integration tests for the firebase.google.com/go/auth package. +package auth import ( "bytes" diff --git a/integration/internal/internal.go b/integration/internal/internal.go index f27a95af..8cd2386f 100644 --- a/integration/internal/internal.go +++ b/integration/internal/internal.go @@ -16,7 +16,9 @@ package internal import ( + "go/build" "io/ioutil" + "path/filepath" "strings" "golang.org/x/net/context" @@ -25,8 +27,14 @@ import ( "google.golang.org/api/option" ) -const certPath = "../testdata/integration_cert.json" -const apiKeyPath = "../testdata/integration_apikey.txt" +const certPath = "integration_cert.json" +const apiKeyPath = "integration_apikey.txt" + +// Resource returns the absolute path to the specified test resource file. +func Resource(name string) string { + p := []string{build.Default.GOPATH, "src", "firebase.google.com", "go", "testdata", name} + return filepath.Join(p...) +} // NewTestApp creates a new App instance for integration tests. // @@ -34,7 +42,7 @@ const apiKeyPath = "../testdata/integration_apikey.txt" // in the testdata directory. This file is used to initialize the newly created // App instance. func NewTestApp(ctx context.Context) (*firebase.App, error) { - return firebase.NewApp(ctx, nil, option.WithCredentialsFile(certPath)) + return firebase.NewApp(ctx, nil, option.WithCredentialsFile(Resource(certPath))) } // APIKey fetches a Firebase API key for integration tests. @@ -42,7 +50,7 @@ func NewTestApp(ctx context.Context) (*firebase.App, error) { // APIKey reads the API key string from a file named integration_apikey.txt // in the testdata directory. func APIKey() (string, error) { - b, err := ioutil.ReadFile(apiKeyPath) + b, err := ioutil.ReadFile(Resource(apiKeyPath)) if err != nil { return "", err }