From 606008d1cd9683e6dca699a1d362df8154be68e4 Mon Sep 17 00:00:00 2001 From: Brian Michalski Date: Wed, 26 Oct 2022 15:13:07 -0400 Subject: [PATCH 1/7] feat: Add App Check token verification (#484) Add API to verify app check tokens --- appcheck/appcheck.go | 176 +++++++++++++++++++++++ appcheck/appcheck_test.go | 289 ++++++++++++++++++++++++++++++++++++++ firebase.go | 9 ++ go.mod | 4 +- go.sum | 4 + internal/internal.go | 5 + testdata/appcheck_pk.pem | 27 ++++ testdata/mock.jwks.json | 12 ++ 8 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 appcheck/appcheck.go create mode 100644 appcheck/appcheck_test.go create mode 100644 testdata/appcheck_pk.pem create mode 100644 testdata/mock.jwks.json diff --git a/appcheck/appcheck.go b/appcheck/appcheck.go new file mode 100644 index 00000000..89868916 --- /dev/null +++ b/appcheck/appcheck.go @@ -0,0 +1,176 @@ +// Copyright 2022 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 appcheck provides functionality for verifying App Check tokens. +package appcheck + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/MicahParks/keyfunc" + "github.com/golang-jwt/jwt/v4" + + "firebase.google.com/go/v4/internal" +) + +// JWKSUrl is the URL of the JWKS used to verify App Check tokens. +var JWKSUrl = "https://firebaseappcheck.googleapis.com/v1beta/jwks" + +const appCheckIssuer = "https://firebaseappcheck.googleapis.com/" + +var ( + // ErrIncorrectAlgorithm is returned when the token is signed with a non-RSA256 algorithm. + ErrIncorrectAlgorithm = errors.New("token has incorrect algorithm") + // ErrTokenType is returned when the token is not a JWT. + ErrTokenType = errors.New("token has incorrect type") + // ErrTokenClaims is returned when the token claims cannot be decoded. + ErrTokenClaims = errors.New("token has incorrect claims") + // ErrTokenAudience is returned when the token audience does not match the current project. + ErrTokenAudience = errors.New("token has incorrect audience") + // ErrTokenIssuer is returned when the token issuer does not match Firebase's App Check service. + ErrTokenIssuer = errors.New("token has incorrect issuer") + // ErrTokenSubject is returned when the token subject is empty or missing. + ErrTokenSubject = errors.New("token has empty or missing subject") +) + +// DecodedAppCheckToken represents a verified App Check token. +// +// DecodedAppCheckToken provides typed accessors to the common JWT fields such as Audience (aud) +// and ExpiresAt (exp). Additionally it provides an AppID field, which indicates the application ID to which this +// token belongs. Any additional JWT claims can be accessed via the Claims map of DecodedAppCheckToken. +type DecodedAppCheckToken struct { + Issuer string + Subject string + Audience []string + ExpiresAt time.Time + IssuedAt time.Time + AppID string + Claims map[string]interface{} +} + +// Client is the interface for the Firebase App Check service. +type Client struct { + projectID string + jwks *keyfunc.JWKS +} + +// NewClient creates a new instance of the Firebase App Check Client. +// +// This function can only be invoked from within the SDK. Client applications should access the +// the App Check service through firebase.App. +func NewClient(ctx context.Context, conf *internal.AppCheckConfig) (*Client, error) { + // TODO: Add support for overriding the HTTP client using the App one. + jwks, err := keyfunc.Get(JWKSUrl, keyfunc.Options{ + Ctx: ctx, + RefreshInterval: 6 * time.Hour, + }) + if err != nil { + return nil, err + } + + return &Client{ + projectID: conf.ProjectID, + jwks: jwks, + }, nil +} + +// VerifyToken verifies the given App Check token. +// +// VerifyToken considers an App Check token string to be valid if all the following conditions are met: +// - The token string is a valid RS256 JWT. +// - The JWT contains valid issuer (iss) and audience (aud) claims that match the issuerPrefix +// and projectID of the tokenVerifier. +// - The JWT contains a valid subject (sub) claim. +// - The JWT is not expired, and it has been issued some time in the past. +// - The JWT is signed by a Firebase App Check backend server as determined by the keySource. +// +// If any of the above conditions are not met, an error is returned. Otherwise a pointer to a +// decoded App Check token is returned. +func (c *Client) VerifyToken(token string) (*DecodedAppCheckToken, error) { + // References for checks: + // https://firebase.googleblog.com/2021/10/protecting-backends-with-app-check.html + // https://github.com/firebase/firebase-admin-node/blob/master/src/app-check/token-verifier.ts#L106 + + // The standard JWT parser also validates the expiration of the token + // so we do not need dedicated code for that. + decodedToken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { + if t.Header["alg"] != "RS256" { + return nil, ErrIncorrectAlgorithm + } + if t.Header["typ"] != "JWT" { + return nil, ErrTokenType + } + return c.jwks.Keyfunc(t) + }) + if err != nil { + return nil, err + } + + claims, ok := decodedToken.Claims.(jwt.MapClaims) + if !ok { + return nil, ErrTokenClaims + } + + rawAud := claims["aud"].([]interface{}) + aud := []string{} + for _, v := range rawAud { + aud = append(aud, v.(string)) + } + + if !contains(aud, "projects/"+c.projectID) { + return nil, ErrTokenAudience + } + + // We check the prefix to make sure this token was issued + // by the Firebase App Check service, but we do not check the + // Project Number suffix because the Golang SDK only has project ID. + // + // This is consistent with the Firebase Admin Node SDK. + if !strings.HasPrefix(claims["iss"].(string), appCheckIssuer) { + return nil, ErrTokenIssuer + } + + if val, ok := claims["sub"].(string); !ok || val == "" { + return nil, ErrTokenSubject + } + + appCheckToken := DecodedAppCheckToken{ + Issuer: claims["iss"].(string), + Subject: claims["sub"].(string), + Audience: aud, + ExpiresAt: time.Unix(int64(claims["exp"].(float64)), 0), + IssuedAt: time.Unix(int64(claims["iat"].(float64)), 0), + AppID: claims["sub"].(string), + } + + // Remove all the claims we've already parsed. + for _, usedClaim := range []string{"iss", "sub", "aud", "exp", "iat", "sub"} { + delete(claims, usedClaim) + } + appCheckToken.Claims = claims + + return &appCheckToken, nil +} + +func contains(s []string, str string) bool { + for _, v := range s { + if v == str { + return true + } + } + return false +} diff --git a/appcheck/appcheck_test.go b/appcheck/appcheck_test.go new file mode 100644 index 00000000..6cd088c0 --- /dev/null +++ b/appcheck/appcheck_test.go @@ -0,0 +1,289 @@ +package appcheck + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "firebase.google.com/go/v4/internal" + "github.com/golang-jwt/jwt/v4" + "github.com/google/go-cmp/cmp" +) + +func TestVerifyTokenHasValidClaims(t *testing.T) { + ts, err := setupFakeJWKS() + if err != nil { + t.Fatalf("Error setting up fake JWKS server: %v", err) + } + defer ts.Close() + + privateKey, err := loadPrivateKey() + if err != nil { + t.Fatalf("Error loading private key: %v", err) + } + + JWKSUrl = ts.URL + conf := &internal.AppCheckConfig{ + ProjectID: "project_id", + } + + client, err := NewClient(context.Background(), conf) + if err != nil { + t.Errorf("Error creating NewClient: %v", err) + } + + type appCheckClaims struct { + Aud []string `json:"aud"` + jwt.RegisteredClaims + } + + mockTime := time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC) + jwt.TimeFunc = func() time.Time { + return mockTime + } + + tokenTests := []struct { + claims *appCheckClaims + wantErr error + wantToken *DecodedAppCheckToken + }{ + { + &appCheckClaims{ + []string{"projects/12345678", "projects/project_id"}, + jwt.RegisteredClaims{ + Issuer: "https://firebaseappcheck.googleapis.com/12345678", + Subject: "12345678:app:ID", + ExpiresAt: jwt.NewNumericDate(mockTime.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(mockTime), + }}, + nil, + &DecodedAppCheckToken{ + Issuer: "https://firebaseappcheck.googleapis.com/12345678", + Subject: "12345678:app:ID", + Audience: []string{"projects/12345678", "projects/project_id"}, + ExpiresAt: mockTime.Add(time.Hour), + IssuedAt: mockTime, + AppID: "12345678:app:ID", + Claims: map[string]interface{}{}, + }, + }, { + &appCheckClaims{ + []string{"projects/12345678", "projects/project_id"}, + jwt.RegisteredClaims{ + Issuer: "https://firebaseappcheck.googleapis.com/12345678", + Subject: "12345678:app:ID", + ExpiresAt: jwt.NewNumericDate(mockTime.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(mockTime), + // A field our AppCheckToken does not use. + NotBefore: jwt.NewNumericDate(mockTime.Add(-1 * time.Hour)), + }}, + nil, + &DecodedAppCheckToken{ + Issuer: "https://firebaseappcheck.googleapis.com/12345678", + Subject: "12345678:app:ID", + Audience: []string{"projects/12345678", "projects/project_id"}, + ExpiresAt: mockTime.Add(time.Hour), + IssuedAt: mockTime, + AppID: "12345678:app:ID", + Claims: map[string]interface{}{ + "nbf": float64(mockTime.Add(-1 * time.Hour).Unix()), + }, + }, + }, { + &appCheckClaims{ + []string{"projects/0000000", "projects/another_project_id"}, + jwt.RegisteredClaims{ + Issuer: "https://firebaseappcheck.googleapis.com/12345678", + Subject: "12345678:app:ID", + ExpiresAt: jwt.NewNumericDate(mockTime.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(mockTime), + }}, + ErrTokenAudience, + nil, + }, { + &appCheckClaims{ + []string{"projects/12345678", "projects/project_id"}, + jwt.RegisteredClaims{ + Issuer: "https://not-firebaseappcheck.googleapis.com/12345678", + Subject: "12345678:app:ID", + ExpiresAt: jwt.NewNumericDate(mockTime.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(mockTime), + }}, + ErrTokenIssuer, + nil, + }, { + &appCheckClaims{ + []string{"projects/12345678", "projects/project_id"}, + jwt.RegisteredClaims{ + Issuer: "https://firebaseappcheck.googleapis.com/12345678", + Subject: "", + ExpiresAt: jwt.NewNumericDate(mockTime.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(mockTime), + }}, + ErrTokenSubject, + nil, + }, { + &appCheckClaims{ + []string{"projects/12345678", "projects/project_id"}, + jwt.RegisteredClaims{ + Issuer: "https://firebaseappcheck.googleapis.com/12345678", + ExpiresAt: jwt.NewNumericDate(mockTime.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(mockTime), + }}, + ErrTokenSubject, + nil, + }, + } + + for _, tc := range tokenTests { + // Create an App Check-style token. + jwtToken := jwt.NewWithClaims(jwt.SigningMethodRS256, tc.claims) + + // kid matches the key ID in testdata/mock.jwks.json, + // which is the public key matching to the private key + // in testdata/appcheck_pk.pem. + jwtToken.Header["kid"] = "FGQdnRlzAmKyKr6-Hg_kMQrBkj_H6i6ADnBQz4OI6BU" + + token, err := jwtToken.SignedString(privateKey) + if err != nil { + t.Fatalf("error generating JWT: %v", err) + } + + // Verify the token. + gotToken, gotErr := client.VerifyToken(token) + if !errors.Is(gotErr, tc.wantErr) { + t.Errorf("Expected error %v, got %v", tc.wantErr, gotErr) + continue + } + if diff := cmp.Diff(tc.wantToken, gotToken); diff != "" { + t.Errorf("VerifyToken mismatch (-want +got):\n%s", diff) + } + } +} + +func TestVerifyTokenMustExist(t *testing.T) { + ts, err := setupFakeJWKS() + if err != nil { + t.Fatalf("Error setting up fake JWK server: %v", err) + } + defer ts.Close() + + JWKSUrl = ts.URL + conf := &internal.AppCheckConfig{ + ProjectID: "project_id", + } + + client, err := NewClient(context.Background(), conf) + if err != nil { + t.Errorf("Error creating NewClient: %v", err) + } + + for _, token := range []string{"", "-", "."} { + gotToken, gotErr := client.VerifyToken(token) + if gotErr == nil { + t.Errorf("VerifyToken(%s) expected error, got nil", token) + } + if gotToken != nil { + t.Errorf("Expected nil, got token %v", gotToken) + } + } +} + +func TestVerifyTokenNotExpired(t *testing.T) { + ts, err := setupFakeJWKS() + if err != nil { + t.Fatalf("Error setting up fake JWKS server: %v", err) + } + defer ts.Close() + + privateKey, err := loadPrivateKey() + if err != nil { + t.Fatalf("Error loading private key: %v", err) + } + + JWKSUrl = ts.URL + conf := &internal.AppCheckConfig{ + ProjectID: "project_id", + } + + client, err := NewClient(context.Background(), conf) + if err != nil { + t.Errorf("Error creating NewClient: %v", err) + } + + mockTime := time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC) + jwt.TimeFunc = func() time.Time { + return mockTime + } + + tokenTests := []struct { + expiresAt time.Time + wantErr bool + }{ + // Expire in the future is OK. + {mockTime.Add(time.Hour), false}, + // Expire in the past is not OK. + {mockTime.Add(-1 * time.Hour), true}, + } + + for _, tc := range tokenTests { + claims := struct { + Aud []string `json:"aud"` + jwt.RegisteredClaims + }{ + []string{"projects/12345678", "projects/project_id"}, + jwt.RegisteredClaims{ + Issuer: "https://firebaseappcheck.googleapis.com/12345678", + Subject: "12345678:app:ID", + ExpiresAt: jwt.NewNumericDate(tc.expiresAt), + IssuedAt: jwt.NewNumericDate(mockTime), + }, + } + + jwtToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + jwtToken.Header["kid"] = "FGQdnRlzAmKyKr6-Hg_kMQrBkj_H6i6ADnBQz4OI6BU" + + token, err := jwtToken.SignedString(privateKey) + if err != nil { + t.Fatalf("error generating JWT: %v", err) + } + + _, gotErr := client.VerifyToken(token) + if tc.wantErr && gotErr == nil { + t.Errorf("Expected an error, got none") + } else if !tc.wantErr && gotErr != nil { + t.Errorf("Expected no error, got %v", gotErr) + } + } +} + +func setupFakeJWKS() (*httptest.Server, error) { + jwks, err := os.ReadFile("../testdata/mock.jwks.json") + if err != nil { + return nil, err + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(jwks) + })) + return ts, nil +} + +func loadPrivateKey() (*rsa.PrivateKey, error) { + pk, err := os.ReadFile("../testdata/appcheck_pk.pem") + if err != nil { + return nil, err + } + block, _ := pem.Decode(pk) + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + return privateKey, nil +} diff --git a/firebase.go b/firebase.go index 1df16d86..de997c57 100644 --- a/firebase.go +++ b/firebase.go @@ -25,6 +25,7 @@ import ( "os" "cloud.google.com/go/firestore" + "firebase.google.com/go/v4/appcheck" "firebase.google.com/go/v4/auth" "firebase.google.com/go/v4/db" "firebase.google.com/go/v4/iid" @@ -128,6 +129,14 @@ func (a *App) Messaging(ctx context.Context) (*messaging.Client, error) { return messaging.NewClient(ctx, conf) } +// AppCheck returns an instance of appcheck.Client. +func (a *App) AppCheck(ctx context.Context) (*appcheck.Client, error) { + conf := &internal.AppCheckConfig{ + ProjectID: a.projectID, + } + return appcheck.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/go.mod b/go.mod index 2986b83b..e1ec5479 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.17 require ( cloud.google.com/go/firestore v1.6.1 cloud.google.com/go/storage v1.26.0 + github.com/MicahParks/keyfunc v1.5.1 + github.com/golang-jwt/jwt/v4 v4.4.2 + github.com/google/go-cmp v0.5.8 golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 google.golang.org/api v0.96.0 google.golang.org/appengine/v2 v2.0.2 @@ -16,7 +19,6 @@ require ( cloud.google.com/go/iam v0.3.0 // indirect github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect github.com/golang/protobuf v1.5.2 // indirect - github.com/google/go-cmp v0.5.8 // indirect github.com/google/uuid v1.3.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect github.com/googleapis/gax-go/v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index abe704bc..45c737e2 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,8 @@ cloud.google.com/go/storage v1.26.0/go.mod h1:mk/N7YwIKEWyTvXAWQCIeiCTdLoRH6Pd5x dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/MicahParks/keyfunc v1.5.1 h1:RlyyYgKQI/adkIw1yXYtPvTAOb7hBhSX42aH23d8N0Q= +github.com/MicahParks/keyfunc v1.5.1/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -97,6 +99,8 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= diff --git a/internal/internal.go b/internal/internal.go index cb525147..287391c5 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -73,6 +73,11 @@ type MessagingConfig struct { Version string } +// AppCheckConfig represents the configuration of App Check service. +type AppCheckConfig struct { + ProjectID string +} + // MockTokenSource is a TokenSource implementation that can be used for testing. type MockTokenSource struct { AccessToken string diff --git a/testdata/appcheck_pk.pem b/testdata/appcheck_pk.pem new file mode 100644 index 00000000..b933ba30 --- /dev/null +++ b/testdata/appcheck_pk.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEArFYQyEdjj43mnpXwj+3WgAE01TSYe1+XFE9mxUDShysFwtVZ +OHFSMm6kl+B3Y/O8NcPt5osntLlH6KHvygExAE0tDmFYq8aKt7LQQF8rTv0rI6MP +92ezyCEp4MPmAPFD/tY160XGrkqApuY2/+L8eEXdkRyH2H7lCYypFC0u3DIY25Vl +q+ZDkxB2kGykGgb1zVazCDDViqV1p9hSltmm4el9AyF08FsMCpk/NvwKOY4pJ/sm +99CDKxMhQBaT9lrIQt0B1VqTpEwlOoiFiyXASRXp9ZTeL4mrLPqSeozwPvspD81w +bgecd62F640scKBr3ko73L8M8UWcwgd+moKCJwIDAQABAoIBAEDPJQSMhE6KKL5e +2NbntJDy4zGC1A0hh6llqtpnZETc0w/QN/tX8ndw0IklKwD1ukPl6OOYVVhLjVVZ +ANpQ1GKuo1ETHsuKoMQwhMyQfbL41m5SdkCuSRfsENmsEiUslkuRtzlBRlRpRDR/ +wxM8A4IflBFsT1IFdpC+yx8BVuwLc35iVnaGQpo/jhSDibt07j+FdOKEWkMGj+rL +sHC6cpB2NMTBl9CIDLW/eq1amBOAGtsSKqoGJvaQY/mZf7SPkRjYIfIl2PWSaduT +fmMrsYYFtHUKVOMYAD7P5RWNkS8oERucnXT3ouAECvip3Ew2JqlQc0FP7FS5CxH3 +WdfvLuECgYEA8Q7rJrDOdO867s7P/lXMklbAGnuNnAZJdAEXUMIaPJi7al97F119 +4DKBuF7c/dDf8CdiOvMzP8r/F8+FFx2D61xxkQNeuxo5Xjlt23OzW5EI2S6ABesZ +/3sQWqvKCGuqN7WENYF3EiKyByQ22MYXk8CE7KZuO57Aj88t6TsaNhkCgYEAtwSs +hbqKSCneC1bQ3wfSAF2kPYRrQEEa2VCLlX1Mz7zHufxksUWAnAbU8O3hIGnXjz6T +qzivyJJhFSgNGeYpwV67GfXnibpr3OZ/yx2YXIQfp0daivj++kvEU7aNfM9rHZA9 +S3Gh7hKELdB9b0DkrX5GpLiZWA6NnJdrIRYbAj8CgYBCZSyJvJsxBA+EZTxOvk0Z +ZYGGCc/oUKb8p6xHVx8o35yHYQMjXWHlVaP7J03RLy3vFLnuqLvN71ixszviMQP7 +2LuDCJ2YBVIVzNWgY07cgqcgQrmKZ8YCY2AOyVBdX2JD8+AVaLJmMV49r1DYBj/K +N3WlRPYJv+Ej+xmXKus+SQKBgHh/Zkthxxu+HQigL0M4teYxwSoTnj2e39uGsXBK +ICGCLIniiDVDCmswAFFkfV3G8frI+5a26t2Gqs6wIPgVVxaOlWeBROGkUNIPHMKR +iLgY8XJEg3OOfuoyql9niP5M3jyHtCOQ/Elv/YDgjUWLl0Q3KLHZLHUSl+AqvYj6 +MewnAoGBANgYzPZgP+wreI55BFR470blKh1mFz+YGa+53DCd7JdMH2pdp4hoh303 +XxpOSVlAuyv9SgTsZ7WjGO5UdhaBzVPKgN0OO6JQmQ5ZrOR8ZJ7VB73FiVHCEerj +1m2zyFv6OT7vqdg+V1/SzxMEmXXFQv1g69k6nWGazne3IJlzrSpj +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/testdata/mock.jwks.json b/testdata/mock.jwks.json new file mode 100644 index 00000000..1ca417cb --- /dev/null +++ b/testdata/mock.jwks.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "kty": "RSA", + "e": "AQAB", + "use": "sig", + "kid": "FGQdnRlzAmKyKr6-Hg_kMQrBkj_H6i6ADnBQz4OI6BU", + "alg": "RS256", + "n": "rFYQyEdjj43mnpXwj-3WgAE01TSYe1-XFE9mxUDShysFwtVZOHFSMm6kl-B3Y_O8NcPt5osntLlH6KHvygExAE0tDmFYq8aKt7LQQF8rTv0rI6MP92ezyCEp4MPmAPFD_tY160XGrkqApuY2_-L8eEXdkRyH2H7lCYypFC0u3DIY25Vlq-ZDkxB2kGykGgb1zVazCDDViqV1p9hSltmm4el9AyF08FsMCpk_NvwKOY4pJ_sm99CDKxMhQBaT9lrIQt0B1VqTpEwlOoiFiyXASRXp9ZTeL4mrLPqSeozwPvspD81wbgecd62F640scKBr3ko73L8M8UWcwgd-moKCJw" + } + ] + } \ No newline at end of file From d6b0ca5be225414f678bc515f74d27217e44530f Mon Sep 17 00:00:00 2001 From: rlaforge Date: Fri, 28 Oct 2022 13:58:47 -0600 Subject: [PATCH 2/7] feat: add go rtdb emulator support (#517) - Added support for the RTDB Emulator --- db/db.go | 103 +++++++++++++++++++++++++++-- db/db_test.go | 134 ++++++++++++++++++++++++++------------ integration/db/db_test.go | 51 +++++++++++++-- 3 files changed, 233 insertions(+), 55 deletions(-) diff --git a/db/db.go b/db/db.go index dbec9185..f53a197c 100644 --- a/db/db.go +++ b/db/db.go @@ -18,36 +18,58 @@ package db import ( "context" "encoding/json" + "errors" "fmt" "net/url" + "os" "runtime" "strings" "firebase.google.com/go/v4/internal" + "golang.org/x/oauth2" "google.golang.org/api/option" ) const userAgentFormat = "Firebase/HTTP/%s/%s/AdminGo" const invalidChars = "[].#$" const authVarOverride = "auth_variable_override" +const emulatorDatabaseEnvVar = "FIREBASE_DATABASE_EMULATOR_HOST" +const emulatorNamespaceParam = "ns" + +// errInvalidURL tells whether the given database url is invalid +// It is invalid if it is malformed, or not of the format "host:port" +var errInvalidURL = errors.New("invalid database url") + +var emulatorToken = &oauth2.Token{ + AccessToken: "owner", +} // Client is the interface for the Firebase Realtime Database service. type Client struct { hc *internal.HTTPClient - url string + dbURLConfig *dbURLConfig authOverride string } +type dbURLConfig struct { + // BaseURL can be either: + // - a production url (https://foo-bar.firebaseio.com/) + // - an emulator url (http://localhost:9000) + BaseURL string + + // Namespace is used in for the emulator to specify the databaseName + // To specify a namespace on your url, pass ns= (localhost:9000/?ns=foo-bar) + Namespace string +} + // NewClient creates a new instance of the Firebase Database Client. // // This function can only be invoked from within the SDK. Client applications should access the // Database service through firebase.App. func NewClient(ctx context.Context, c *internal.DatabaseConfig) (*Client, error) { - p, err := url.ParseRequestURI(c.URL) + urlConfig, isEmulator, err := parseURLConfig(c.URL) if err != nil { return nil, err - } else if p.Scheme != "https" { - return nil, fmt.Errorf("invalid database URL: %q; want scheme: %q", c.URL, "https") } var ao []byte @@ -59,6 +81,10 @@ func NewClient(ctx context.Context, c *internal.DatabaseConfig) (*Client, error) } opts := append([]option.ClientOption{}, c.Opts...) + if isEmulator { + ts := oauth2.StaticTokenSource(emulatorToken) + opts = append(opts, option.WithTokenSource(ts)) + } ua := fmt.Sprintf(userAgentFormat, c.Version, runtime.Version()) opts = append(opts, option.WithUserAgent(ua)) hc, _, err := internal.NewHTTPClient(ctx, opts...) @@ -69,7 +95,7 @@ func NewClient(ctx context.Context, c *internal.DatabaseConfig) (*Client, error) hc.CreateErrFn = handleRTDBError return &Client{ hc: hc, - url: fmt.Sprintf("https://%s", p.Host), + dbURLConfig: urlConfig, authOverride: string(ao), }, nil } @@ -96,10 +122,13 @@ func (c *Client) sendAndUnmarshal( return nil, fmt.Errorf("invalid path with illegal characters: %q", req.URL) } - req.URL = fmt.Sprintf("%s%s.json", c.url, req.URL) + req.URL = fmt.Sprintf("%s%s.json", c.dbURLConfig.BaseURL, req.URL) if c.authOverride != "" { req.Opts = append(req.Opts, internal.WithQueryParam(authVarOverride, c.authOverride)) } + if c.dbURLConfig.Namespace != "" { + req.Opts = append(req.Opts, internal.WithQueryParam(emulatorNamespaceParam, c.dbURLConfig.Namespace)) + } return c.hc.DoAndUnmarshal(ctx, req, v) } @@ -126,3 +155,65 @@ func handleRTDBError(resp *internal.Response) error { return err } + +// parseURLConfig returns the dbURLConfig for the database +// dbURL may be either: +// - a production url (https://foo-bar.firebaseio.com/) +// - an emulator URL (localhost:9000/?ns=foo-bar) +// +// The following rules will apply for determining the output: +// - If the url does not use an https scheme it will be assumed to be an emulator url and be used. +// - else If the FIREBASE_DATABASE_EMULATOR_HOST environment variable is set it will be used. +// - else the url will be assumed to be a production url and be used. +func parseURLConfig(dbURL string) (*dbURLConfig, bool, error) { + parsedURL, err := url.ParseRequestURI(dbURL) + if err == nil && parsedURL.Scheme != "https" { + cfg, err := parseEmulatorHost(dbURL, parsedURL) + return cfg, true, err + } + + environmentEmulatorURL := os.Getenv(emulatorDatabaseEnvVar) + if environmentEmulatorURL != "" { + parsedURL, err = url.ParseRequestURI(environmentEmulatorURL) + if err != nil { + return nil, false, fmt.Errorf("%s: %w", environmentEmulatorURL, errInvalidURL) + } + cfg, err := parseEmulatorHost(environmentEmulatorURL, parsedURL) + return cfg, true, err + } + + if err != nil { + return nil, false, fmt.Errorf("%s: %w", dbURL, errInvalidURL) + } + + return &dbURLConfig{ + BaseURL: dbURL, + Namespace: "", + }, false, nil +} + +func parseEmulatorHost(rawEmulatorHostURL string, parsedEmulatorHost *url.URL) (*dbURLConfig, error) { + if strings.Contains(rawEmulatorHostURL, "//") { + return nil, fmt.Errorf(`invalid %s: "%s". It must follow format "host:port": %w`, emulatorDatabaseEnvVar, rawEmulatorHostURL, errInvalidURL) + } + + baseURL := strings.Replace(rawEmulatorHostURL, fmt.Sprintf("?%s", parsedEmulatorHost.RawQuery), "", -1) + if parsedEmulatorHost.Scheme != "http" { + baseURL = fmt.Sprintf("http://%s", baseURL) + } + + namespace := parsedEmulatorHost.Query().Get(emulatorNamespaceParam) + if namespace == "" { + if strings.Contains(rawEmulatorHostURL, ".") { + namespace = strings.Split(rawEmulatorHostURL, ".")[0] + } + if namespace == "" { + return nil, fmt.Errorf(`invalid database URL: "%s". Database URL must be a valid URL to a Firebase Realtime Database instance (include ?ns= query param)`, parsedEmulatorHost) + } + } + + return &dbURLConfig{ + BaseURL: baseURL, + Namespace: namespace, + }, nil +} diff --git a/db/db_test.go b/db/db_test.go index e5a959e1..5f6ba047 100644 --- a/db/db_test.go +++ b/db/db_test.go @@ -33,8 +33,11 @@ import ( ) const ( - testURL = "https://test-db.firebaseio.com" - defaultMaxRetries = 1 + testURL = "https://test-db.firebaseio.com" + testEmulatorNamespace = "test-db" + testEmulatorBaseURL = "http://localhost:9000" + testEmulatorURL = "localhost:9000?ns=test-db" + defaultMaxRetries = 1 ) var ( @@ -87,52 +90,96 @@ func TestMain(m *testing.M) { } func TestNewClient(t *testing.T) { - c, err := NewClient(context.Background(), &internal.DatabaseConfig{ - Opts: testOpts, - URL: testURL, - AuthOverride: make(map[string]interface{}), - }) - if err != nil { - t.Fatal(err) - } - if c.url != testURL { - t.Errorf("NewClient().url = %q; want = %q", c.url, testURL) - } - if c.hc == nil { - t.Errorf("NewClient().hc = nil; want non-nil") + cases := []*struct { + Name string + URL string + EnvURL string + ExpectedBaseURL string + ExpectedNamespace string + ExpectError bool + }{ + {Name: "production url", URL: testURL, ExpectedBaseURL: testURL, ExpectedNamespace: ""}, + {Name: "emulator - success", URL: testEmulatorURL, ExpectedBaseURL: testEmulatorBaseURL, ExpectedNamespace: testEmulatorNamespace}, + {Name: "emulator - missing namespace should error", URL: "localhost:9000", ExpectError: true}, + {Name: "emulator - if url contains hostname it uses the primary domain", URL: "rtdb-go.emulator:9000", ExpectedBaseURL: "http://rtdb-go.emulator:9000", ExpectedNamespace: "rtdb-go"}, + {Name: "emulator env - success", EnvURL: testEmulatorURL, ExpectedBaseURL: testEmulatorBaseURL, ExpectedNamespace: testEmulatorNamespace}, } - if c.authOverride != "" { - t.Errorf("NewClient().ao = %q; want = %q", c.authOverride, "") + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + t.Setenv(emulatorDatabaseEnvVar, tc.EnvURL) + fromEnv := os.Getenv(emulatorDatabaseEnvVar) + fmt.Printf(fromEnv) + c, err := NewClient(context.Background(), &internal.DatabaseConfig{ + Opts: testOpts, + URL: tc.URL, + AuthOverride: make(map[string]interface{}), + }) + if err != nil && tc.ExpectError { + return + } + if err != nil && !tc.ExpectError { + t.Fatal(err) + } + if err == nil && tc.ExpectError { + t.Fatal("expected error") + } + if c.dbURLConfig.BaseURL != tc.ExpectedBaseURL { + t.Errorf("NewClient().dbURLConfig.BaseURL = %q; want = %q", c.dbURLConfig.BaseURL, tc.ExpectedBaseURL) + } + if c.dbURLConfig.Namespace != tc.ExpectedNamespace { + t.Errorf("NewClient(%v).Namespace = %q; want = %q", tc, c.dbURLConfig.Namespace, tc.ExpectedNamespace) + } + if c.hc == nil { + t.Errorf("NewClient().hc = nil; want non-nil") + } + if c.authOverride != "" { + t.Errorf("NewClient().ao = %q; want = %q", c.authOverride, "") + } + }) } } func TestNewClientAuthOverrides(t *testing.T) { - cases := []map[string]interface{}{ - nil, - {"uid": "user1"}, + cases := []*struct { + Name string + Params map[string]interface{} + URL string + ExpectedBaseURL string + ExpectedNamespace string + }{ + {Name: "production - without override", Params: nil, URL: testURL, ExpectedBaseURL: testURL, ExpectedNamespace: ""}, + {Name: "production - with override", Params: map[string]interface{}{"uid": "user1"}, URL: testURL, ExpectedBaseURL: testURL, ExpectedNamespace: ""}, + + {Name: "emulator - with no query params", Params: nil, URL: testEmulatorURL, ExpectedBaseURL: testEmulatorBaseURL, ExpectedNamespace: testEmulatorNamespace}, + {Name: "emulator - with override", Params: map[string]interface{}{"uid": "user1"}, URL: testEmulatorURL, ExpectedBaseURL: testEmulatorBaseURL, ExpectedNamespace: testEmulatorNamespace}, } for _, tc := range cases { - c, err := NewClient(context.Background(), &internal.DatabaseConfig{ - Opts: testOpts, - URL: testURL, - AuthOverride: tc, + t.Run(tc.Name, func(t *testing.T) { + c, err := NewClient(context.Background(), &internal.DatabaseConfig{ + Opts: testOpts, + URL: tc.URL, + AuthOverride: tc.Params, + }) + if err != nil { + t.Fatal(err) + } + if c.dbURLConfig.BaseURL != tc.ExpectedBaseURL { + t.Errorf("NewClient(%v).baseURL = %q; want = %q", tc, c.dbURLConfig.BaseURL, tc.ExpectedBaseURL) + } + if c.dbURLConfig.Namespace != tc.ExpectedNamespace { + t.Errorf("NewClient(%v).Namespace = %q; want = %q", tc, c.dbURLConfig.Namespace, tc.ExpectedNamespace) + } + if c.hc == nil { + t.Errorf("NewClient(%v).hc = nil; want non-nil", tc) + } + b, err := json.Marshal(tc.Params) + if err != nil { + t.Fatal(err) + } + if c.authOverride != string(b) { + t.Errorf("NewClient(%v).ao = %q; want = %q", tc, c.authOverride, string(b)) + } }) - if err != nil { - t.Fatal(err) - } - if c.url != testURL { - t.Errorf("NewClient(%v).url = %q; want = %q", tc, c.url, testURL) - } - if c.hc == nil { - t.Errorf("NewClient(%v).hc = nil; want non-nil", tc) - } - b, err := json.Marshal(tc) - if err != nil { - t.Fatal(err) - } - if c.authOverride != string(b) { - t.Errorf("NewClient(%v).ao = %q; want = %q", tc, c.authOverride, string(b)) - } } } @@ -149,8 +196,8 @@ func TestValidURLS(t *testing.T) { if err != nil { t.Fatal(err) } - if c.url != tc { - t.Errorf("NewClient(%v).url = %q; want = %q", tc, c.url, testURL) + if c.dbURLConfig.BaseURL != tc { + t.Errorf("NewClient(%v).url = %q; want = %q", tc, c.dbURLConfig.BaseURL, testURL) } } } @@ -161,6 +208,7 @@ func TestInvalidURL(t *testing.T) { "foo", "http://db.firebaseio.com", "http://firebase.google.com", + "http://localhost:9000", } for _, tc := range cases { c, err := NewClient(context.Background(), &internal.DatabaseConfig{ @@ -402,7 +450,7 @@ func (s *mockServer) Start(c *Client) *httptest.Server { w.Write(b) }) s.srv = httptest.NewServer(handler) - c.url = s.srv.URL + c.dbURLConfig.BaseURL = s.srv.URL return s.srv } diff --git a/integration/db/db_test.go b/integration/db/db_test.go index fc34b5ff..93be1eef 100644 --- a/integration/db/db_test.go +++ b/integration/db/db_test.go @@ -86,8 +86,12 @@ func TestMain(m *testing.M) { func initClient(pid string) (*db.Client, error) { ctx := context.Background() + url, err := getDatabaseURL() + if err != nil { + return nil, err + } app, err := internal.NewTestApp(ctx, &firebase.Config{ - DatabaseURL: fmt.Sprintf("https://%s.firebaseio.com", pid), + DatabaseURL: url, }) if err != nil { return nil, err @@ -99,8 +103,12 @@ func initClient(pid string) (*db.Client, error) { func initOverrideClient(pid string) (*db.Client, error) { ctx := context.Background() ao := map[string]interface{}{"uid": "user1"} + url, err := getDatabaseURL() + if err != nil { + return nil, err + } app, err := internal.NewTestApp(ctx, &firebase.Config{ - DatabaseURL: fmt.Sprintf("https://%s.firebaseio.com", pid), + DatabaseURL: url, AuthOverride: &ao, }) if err != nil { @@ -113,8 +121,12 @@ func initOverrideClient(pid string) (*db.Client, error) { func initGuestClient(pid string) (*db.Client, error) { ctx := context.Background() var nullMap map[string]interface{} + url, err := getDatabaseURL() + if err != nil { + return nil, err + } app, err := internal.NewTestApp(ctx, &firebase.Config{ - DatabaseURL: fmt.Sprintf("https://%s.firebaseio.com", pid), + DatabaseURL: url, AuthOverride: &nullMap, }) if err != nil { @@ -130,12 +142,10 @@ func initRules() { log.Fatalln(err) } - pid, err := internal.ProjectID() + url, err := getDatabaseRulesURL() if err != nil { log.Fatalln(err) } - - url := fmt.Sprintf("https://%s.firebaseio.com/.settings/rules.json", pid) req, err := http.NewRequest("PUT", url, bytes.NewBuffer(b)) if err != nil { log.Fatalln(err) @@ -750,3 +760,32 @@ type User struct { Name string `json:"name"` Since int `json:"since"` } + +func getDatabaseRulesURL() (string, error) { + emulatorHost := os.Getenv("FIREBASE_DATABASE_EMULATOR_HOST") + if emulatorHost != "" { + return fmt.Sprintf("http://%s/.settings/rules.json?ns=%s", emulatorHost, os.Getenv("FIREBASE_DATABASE_EMULATOR_NAMESPACE")), nil + } + prodURL, err := getProductionURL() + if err != nil { + return "", err + } + return fmt.Sprintf("%s/.settings/rules.json", prodURL), nil +} + +func getDatabaseURL() (string, error) { + emulatorHost := os.Getenv("FIREBASE_DATABASE_EMULATOR_HOST") + if emulatorHost != "" { + return fmt.Sprintf("%s?ns=%s", emulatorHost, os.Getenv("FIREBASE_DATABASE_EMULATOR_NAMESPACE")), nil + } + return getProductionURL() +} + +func getProductionURL() (string, error) { + pid, err := internal.ProjectID() + if err != nil { + return "", err + } + + return fmt.Sprintf("https://%s.firebaseio.com", pid), nil +} From 84aa056ea0980006701dc2c3bb27c3f7d391cdf9 Mon Sep 17 00:00:00 2001 From: pragatimodi <110490169+pragatimodi@users.noreply.github.com> Date: Wed, 9 Nov 2022 11:32:13 -0800 Subject: [PATCH 3/7] feat(auth): MFA Support for GoLang createUser, updateUser (#511) * Golang MFA support MFA Support for golang - draft * Some changes * Fixing unit tests * Fixing lint issues * Error fix : missing go.sum entry for module providing package golang.org/x/lint/golint * Resolved comments * Added an integration test to create a user with non-null MFA values and addressing comments * Addressing comments --- auth/user_mgt.go | 92 +++++++++++++++- auth/user_mgt_test.go | 169 ++++++++++++++++++++++++++++++ go.sum | 1 + integration/auth/user_mgt_test.go | 66 ++++++++++++ 4 files changed, 324 insertions(+), 4 deletions(-) diff --git a/auth/user_mgt.go b/auth/user_mgt.go index 333416e4..8926e249 100644 --- a/auth/user_mgt.go +++ b/auth/user_mgt.go @@ -39,6 +39,9 @@ const ( // Maximum number of users allowed to batch delete at a time. maxDeleteAccountsBatchSize = 1000 + createUserMethod = "createUser" + updateUserMethod = "updateUser" + phoneMultiFactorID = "phone" ) // 'REDACTED', encoded as a base64 string. @@ -66,6 +69,7 @@ type multiFactorInfoResponse struct { } // MultiFactorInfo describes a user enrolled second phone factor. +// TODO : convert PhoneNumber to PhoneMultiFactorInfo struct type MultiFactorInfo struct { UID string DisplayName string @@ -147,6 +151,11 @@ func (u *UserToCreate) UID(uid string) *UserToCreate { return u.set("localId", uid) } +// MFASettings setter. +func (u *UserToCreate) MFASettings(mfaSettings MultiFactorSettings) *UserToCreate { + return u.set("mfaSettings", mfaSettings) +} + func (u *UserToCreate) set(key string, value interface{}) *UserToCreate { if u.params == nil { u.params = make(map[string]interface{}) @@ -155,10 +164,34 @@ func (u *UserToCreate) set(key string, value interface{}) *UserToCreate { return u } +// Converts a client format second factor object to server format. +func convertMultiFactorInfoToServerFormat(mfaInfo MultiFactorInfo) (multiFactorInfoResponse, error) { + var authFactorInfo multiFactorInfoResponse + if mfaInfo.EnrollmentTimestamp != 0 { + authFactorInfo.EnrolledAt = time.Unix(mfaInfo.EnrollmentTimestamp, 0).Format("2006-01-02T15:04:05Z07:00Z") + } + if mfaInfo.FactorID == phoneMultiFactorID { + authFactorInfo.PhoneInfo = mfaInfo.PhoneNumber + authFactorInfo.DisplayName = mfaInfo.DisplayName + authFactorInfo.MFAEnrollmentID = mfaInfo.UID + return authFactorInfo, nil + } + out, _ := json.Marshal(mfaInfo) + return multiFactorInfoResponse{}, fmt.Errorf("Unsupported second factor %s provided", string(out)) +} + func (u *UserToCreate) validatedRequest() (map[string]interface{}, error) { req := make(map[string]interface{}) for k, v := range u.params { - req[k] = v + if k == "mfaSettings" { + mfaInfo, err := validateAndFormatMfaSettings(v.(MultiFactorSettings), createUserMethod) + if err != nil { + return nil, err + } + req["mfaInfo"] = mfaInfo + } else { + req[k] = v + } } if uid, ok := req["localId"]; ok { @@ -191,7 +224,6 @@ func (u *UserToCreate) validatedRequest() (map[string]interface{}, error) { return nil, err } } - return req, nil } @@ -241,6 +273,11 @@ func (u *UserToUpdate) PhotoURL(url string) *UserToUpdate { return u.set("photoUrl", url) } +// MFASettings setter. +func (u *UserToUpdate) MFASettings(mfaSettings MultiFactorSettings) *UserToUpdate { + return u.set("mfaSettings", mfaSettings) +} + // ProviderToLink links this user to the specified provider. // // Linking a provider to an existing user account does not invalidate the @@ -291,7 +328,15 @@ func (u *UserToUpdate) validatedRequest() (map[string]interface{}, error) { req := make(map[string]interface{}) for k, v := range u.params { - req[k] = v + if k == "mfaSettings" { + mfaInfo, err := validateAndFormatMfaSettings(v.(MultiFactorSettings), updateUserMethod) + if err != nil { + return nil, err + } + req["mfaInfo"] = mfaInfo + } else { + req[k] = v + } } if email, ok := req["email"]; ok { @@ -604,6 +649,45 @@ func validateProvider(providerID string, providerUID string) error { return nil } +func validateAndFormatMfaSettings(mfaSettings MultiFactorSettings, methodType string) ([]*multiFactorInfoResponse, error) { + var mfaInfo []*multiFactorInfoResponse + for _, multiFactorInfo := range mfaSettings.EnrolledFactors { + if multiFactorInfo.FactorID == "" { + return nil, fmt.Errorf("no factor id specified") + } + switch methodType { + case createUserMethod: + // Enrollment time and uid are not allowed for signupNewUser endpoint. They will automatically be provisioned server side. + if multiFactorInfo.EnrollmentTimestamp != 0 { + return nil, fmt.Errorf("\"EnrollmentTimeStamp\" is not supported when adding second factors via \"createUser()\"") + } + if multiFactorInfo.UID != "" { + return nil, fmt.Errorf("\"uid\" is not supported when adding second factors via \"createUser()\"") + } + case updateUserMethod: + if multiFactorInfo.UID == "" { + return nil, fmt.Errorf("the second factor \"uid\" must be a valid non-empty string when adding second factors via \"updateUser()\"") + } + default: + return nil, fmt.Errorf("unsupported methodType: %s", methodType) + } + if err := validateDisplayName(multiFactorInfo.DisplayName); err != nil { + return nil, fmt.Errorf("the second factor \"displayName\" for \"%s\" must be a valid non-empty string", multiFactorInfo.DisplayName) + } + if multiFactorInfo.FactorID == phoneMultiFactorID { + if err := validatePhone(multiFactorInfo.PhoneNumber); err != nil { + return nil, fmt.Errorf("the second factor \"phoneNumber\" for \"%s\" must be a non-empty E.164 standard compliant identifier string", multiFactorInfo.PhoneNumber) + } + } + obj, err := convertMultiFactorInfoToServerFormat(*multiFactorInfo) + if err != nil { + return nil, err + } + mfaInfo = append(mfaInfo, &obj) + } + return mfaInfo, nil +} + // End of validators // GetUser gets the user data corresponding to the specified user ID. @@ -999,7 +1083,7 @@ func (r *userQueryResponse) makeExportedUserRecord() (*ExportedUserRecord, error UID: factor.MFAEnrollmentID, DisplayName: factor.DisplayName, EnrollmentTimestamp: enrollmentTimestamp, - FactorID: "phone", + FactorID: phoneMultiFactorID, PhoneNumber: factor.PhoneInfo, }) } diff --git a/auth/user_mgt_test.go b/auth/user_mgt_test.go index 17b0f105..aa0f465c 100644 --- a/auth/user_mgt_test.go +++ b/auth/user_mgt_test.go @@ -642,6 +642,62 @@ func TestInvalidCreateUser(t *testing.T) { }, { (&UserToCreate{}).Email("a@a@a"), `malformed email string: "a@a@a"`, + }, { + (&UserToCreate{}).MFASettings(MultiFactorSettings{ + EnrolledFactors: []*MultiFactorInfo{ + { + UID: "EnrollmentID", + PhoneNumber: "+11234567890", + DisplayName: "Spouse's phone number", + FactorID: "phone", + }, + }, + }), + `"uid" is not supported when adding second factors via "createUser()"`, + }, { + (&UserToCreate{}).MFASettings(MultiFactorSettings{ + EnrolledFactors: []*MultiFactorInfo{ + { + PhoneNumber: "invalid", + DisplayName: "Spouse's phone number", + FactorID: "phone", + }, + }, + }), + `the second factor "phoneNumber" for "invalid" must be a non-empty E.164 standard compliant identifier string`, + }, { + (&UserToCreate{}).MFASettings(MultiFactorSettings{ + EnrolledFactors: []*MultiFactorInfo{ + { + PhoneNumber: "+11234567890", + DisplayName: "Spouse's phone number", + FactorID: "phone", + EnrollmentTimestamp: time.Now().UTC().Unix(), + }, + }, + }), + `"EnrollmentTimeStamp" is not supported when adding second factors via "createUser()"`, + }, { + (&UserToCreate{}).MFASettings(MultiFactorSettings{ + EnrolledFactors: []*MultiFactorInfo{ + { + PhoneNumber: "+11234567890", + DisplayName: "Spouse's phone number", + FactorID: "", + }, + }, + }), + `no factor id specified`, + }, { + (&UserToCreate{}).MFASettings(MultiFactorSettings{ + EnrolledFactors: []*MultiFactorInfo{ + { + PhoneNumber: "+11234567890", + FactorID: "phone", + }, + }, + }), + `the second factor "displayName" for "" must be a valid non-empty string`, }, } client := &Client{ @@ -713,6 +769,49 @@ var createUserCases = []struct { { (&UserToCreate{}).PhotoURL("http://some.url"), map[string]interface{}{"photoUrl": "http://some.url"}, + }, { + (&UserToCreate{}).MFASettings(MultiFactorSettings{ + EnrolledFactors: []*MultiFactorInfo{ + { + PhoneNumber: "+11234567890", + DisplayName: "Spouse's phone number", + FactorID: "phone", + }, + }, + }), + map[string]interface{}{"mfaInfo": []*multiFactorInfoResponse{ + { + PhoneInfo: "+11234567890", + DisplayName: "Spouse's phone number", + }, + }, + }, + }, { + (&UserToCreate{}).MFASettings(MultiFactorSettings{ + EnrolledFactors: []*MultiFactorInfo{ + { + PhoneNumber: "+11234567890", + DisplayName: "number1", + FactorID: "phone", + }, + { + PhoneNumber: "+11234567890", + DisplayName: "number2", + FactorID: "phone", + }, + }, + }), + map[string]interface{}{"mfaInfo": []*multiFactorInfoResponse{ + { + PhoneInfo: "+11234567890", + DisplayName: "number1", + }, + { + PhoneInfo: "+11234567890", + DisplayName: "number2", + }, + }, + }, }, } @@ -772,6 +871,40 @@ func TestInvalidUpdateUser(t *testing.T) { }, { (&UserToUpdate{}).Password("short"), "password must be a string at least 6 characters long", + }, { + (&UserToUpdate{}).MFASettings(MultiFactorSettings{ + EnrolledFactors: []*MultiFactorInfo{ + { + UID: "enrolledSecondFactor1", + PhoneNumber: "+11234567890", + FactorID: "phone", + }, + }, + }), + `the second factor "displayName" for "" must be a valid non-empty string`, + }, { + (&UserToUpdate{}).MFASettings(MultiFactorSettings{ + EnrolledFactors: []*MultiFactorInfo{ + { + UID: "enrolledSecondFactor1", + PhoneNumber: "invalid", + DisplayName: "Spouse's phone number", + FactorID: "phone", + }, + }, + }), + `the second factor "phoneNumber" for "invalid" must be a non-empty E.164 standard compliant identifier string`, + }, { + (&UserToUpdate{}).MFASettings(MultiFactorSettings{ + EnrolledFactors: []*MultiFactorInfo{ + { + PhoneNumber: "+11234567890", + FactorID: "phone", + DisplayName: "Spouse's phone number", + }, + }, + }), + `the second factor "uid" must be a valid non-empty string when adding second factors via "updateUser()"`, }, { (&UserToUpdate{}).ProviderToLink(&UserProvider{UID: "google_uid"}), "user provider must specify a provider ID", @@ -912,6 +1045,42 @@ var updateUserCases = []struct { "deleteProvider": []string{"phone"}, }, }, + { + (&UserToUpdate{}).MFASettings(MultiFactorSettings{ + EnrolledFactors: []*MultiFactorInfo{ + { + UID: "enrolledSecondFactor1", + PhoneNumber: "+11234567890", + DisplayName: "Spouse's phone number", + FactorID: "phone", + EnrollmentTimestamp: time.Now().Unix(), + }, { + UID: "enrolledSecondFactor2", + PhoneNumber: "+11234567890", + DisplayName: "Spouse's phone number", + FactorID: "phone", + }, + }, + }), + map[string]interface{}{"mfaInfo": []*multiFactorInfoResponse{ + { + MFAEnrollmentID: "enrolledSecondFactor1", + PhoneInfo: "+11234567890", + DisplayName: "Spouse's phone number", + EnrolledAt: time.Now().Format("2006-01-02T15:04:05Z07:00Z"), + }, + { + MFAEnrollmentID: "enrolledSecondFactor2", + DisplayName: "Spouse's phone number", + PhoneInfo: "+11234567890", + }, + }, + }, + }, + { + (&UserToUpdate{}).MFASettings(MultiFactorSettings{}), + map[string]interface{}{"mfaInfo": nil}, + }, { (&UserToUpdate{}).ProviderToLink(&UserProvider{ ProviderID: "google.com", diff --git a/go.sum b/go.sum index 45c737e2..8a4e74e6 100644 --- a/go.sum +++ b/go.sum @@ -250,6 +250,7 @@ golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRu golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= diff --git a/integration/auth/user_mgt_test.go b/integration/auth/user_mgt_test.go index 71867be8..9b567d17 100644 --- a/integration/auth/user_mgt_test.go +++ b/integration/auth/user_mgt_test.go @@ -427,6 +427,72 @@ func TestCreateUser(t *testing.T) { } } +func TestCreateUserMFA(t *testing.T) { + var tc *auth.UserToCreate = &auth.UserToCreate{} + tc.Email("testuser@example.com") + tc.EmailVerified(true) + tc.MFASettings(auth.MultiFactorSettings{ + EnrolledFactors: []*auth.MultiFactorInfo{ + { + PhoneNumber: "+11234567890", + DisplayName: "Spouse's phone number", + FactorID: "phone", + }, + }, + }) + user, err := client.CreateUser(context.Background(), tc) + if err != nil { + t.Fatalf("CreateUser() = %v; want = nil", err) + } + uidToDelete := user.UID + defer deleteUser(uidToDelete) + var factor []*auth.MultiFactorInfo = []*auth.MultiFactorInfo{ + { + UID: user.MultiFactor.EnrolledFactors[0].UID, + DisplayName: "Spouse's phone number", + FactorID: "phone", + PhoneNumber: "+11234567890", + EnrollmentTimestamp: user.MultiFactor.EnrolledFactors[0].EnrollmentTimestamp, + }, + } + want := auth.UserRecord{ + EmailVerified: true, + UserInfo: &auth.UserInfo{ + Email: "testuser@example.com", + UID: user.UID, + ProviderID: "firebase", + }, + UserMetadata: &auth.UserMetadata{ + CreationTimestamp: user.UserMetadata.CreationTimestamp, + }, + TokensValidAfterMillis: user.TokensValidAfterMillis, + MultiFactor: &auth.MultiFactorSettings{ + EnrolledFactors: factor, + }, + } + if !reflect.DeepEqual(*user, want) { + t.Errorf("CreateUser() = %#v; want = %#v", *user, want) + } + factor = []*auth.MultiFactorInfo{} + user, err = client.CreateUser(context.Background(), (&auth.UserToCreate{}).UID(user.UID)) + want = auth.UserRecord{ + UserInfo: &auth.UserInfo{ + UID: user.UID, + ProviderID: "firebase", + }, + UserMetadata: &auth.UserMetadata{ + CreationTimestamp: user.UserMetadata.CreationTimestamp, + }, + TokensValidAfterMillis: user.TokensValidAfterMillis, + MultiFactor: &auth.MultiFactorSettings{ + EnrolledFactors: factor, + }, + } + if err == nil || user != nil || !auth.IsUIDAlreadyExists(err) { + t.Errorf("CreateUser(existing-uid) = (%#v, %v); want = (%#v, error)", user, err, want) + } +} + func TestUpdateUser(t *testing.T) { // Creates a new user for testing purposes. The user's uid will be // '$name_$tenRandomChars' and email will be From b502f254be0ab4a05f617722dca6cc4610f61bc3 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Thu, 10 Nov 2022 13:59:14 -0500 Subject: [PATCH 4/7] [chore] Release 4.10.0 (#522) - Release 4.10.0 --- firebase.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase.go b/firebase.go index de997c57..1da19303 100644 --- a/firebase.go +++ b/firebase.go @@ -39,7 +39,7 @@ import ( var defaultAuthOverrides = make(map[string]interface{}) // Version of the Firebase Go Admin SDK. -const Version = "4.9.0" +const Version = "4.10.0" // firebaseEnvName is the name of the environment variable with the Config. const firebaseEnvName = "FIREBASE_CONFIG" From 96a4b12acb4797b5c7a0170d4941e0158645d1e1 Mon Sep 17 00:00:00 2001 From: pragatimodi <110490169+pragatimodi@users.noreply.github.com> Date: Thu, 10 Nov 2022 14:01:20 -0800 Subject: [PATCH 5/7] Fixing integration tests : TestCreateUserMFA() (#524) * Fixing MFA integration tests --- integration/auth/user_mgt_test.go | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/integration/auth/user_mgt_test.go b/integration/auth/user_mgt_test.go index 9b567d17..c1301f6d 100644 --- a/integration/auth/user_mgt_test.go +++ b/integration/auth/user_mgt_test.go @@ -444,8 +444,7 @@ func TestCreateUserMFA(t *testing.T) { if err != nil { t.Fatalf("CreateUser() = %v; want = nil", err) } - uidToDelete := user.UID - defer deleteUser(uidToDelete) + defer deleteUser(user.UID) var factor []*auth.MultiFactorInfo = []*auth.MultiFactorInfo{ { UID: user.MultiFactor.EnrolledFactors[0].UID, @@ -473,24 +472,6 @@ func TestCreateUserMFA(t *testing.T) { if !reflect.DeepEqual(*user, want) { t.Errorf("CreateUser() = %#v; want = %#v", *user, want) } - factor = []*auth.MultiFactorInfo{} - user, err = client.CreateUser(context.Background(), (&auth.UserToCreate{}).UID(user.UID)) - want = auth.UserRecord{ - UserInfo: &auth.UserInfo{ - UID: user.UID, - ProviderID: "firebase", - }, - UserMetadata: &auth.UserMetadata{ - CreationTimestamp: user.UserMetadata.CreationTimestamp, - }, - TokensValidAfterMillis: user.TokensValidAfterMillis, - MultiFactor: &auth.MultiFactorSettings{ - EnrolledFactors: factor, - }, - } - if err == nil || user != nil || !auth.IsUIDAlreadyExists(err) { - t.Errorf("CreateUser(existing-uid) = (%#v, %v); want = (%#v, error)", user, err, want) - } } func TestUpdateUser(t *testing.T) { From 39223dad5cff8ff361d8a9805ceda2946c80ed00 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Thu, 10 Nov 2022 17:12:07 -0500 Subject: [PATCH 6/7] Revert "[chore] Release 4.10.0 (#522)" (#525) This reverts commit b502f254be0ab4a05f617722dca6cc4610f61bc3. --- firebase.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase.go b/firebase.go index 1da19303..de997c57 100644 --- a/firebase.go +++ b/firebase.go @@ -39,7 +39,7 @@ import ( var defaultAuthOverrides = make(map[string]interface{}) // Version of the Firebase Go Admin SDK. -const Version = "4.10.0" +const Version = "4.9.0" // firebaseEnvName is the name of the environment variable with the Config. const firebaseEnvName = "FIREBASE_CONFIG" From c3281be7aacab1469a102cb3d7a3d0f095626319 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Thu, 10 Nov 2022 17:21:44 -0500 Subject: [PATCH 7/7] [chore] Release 4.10.0 Take 2 (#526) --- firebase.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase.go b/firebase.go index de997c57..1da19303 100644 --- a/firebase.go +++ b/firebase.go @@ -39,7 +39,7 @@ import ( var defaultAuthOverrides = make(map[string]interface{}) // Version of the Firebase Go Admin SDK. -const Version = "4.9.0" +const Version = "4.10.0" // firebaseEnvName is the name of the environment variable with the Config. const firebaseEnvName = "FIREBASE_CONFIG"