diff --git a/appcheck/appcheck.go b/appcheck/appcheck.go index c2fefaa8..033331fa 100644 --- a/appcheck/appcheck.go +++ b/appcheck/appcheck.go @@ -18,6 +18,7 @@ package appcheck import ( "context" "errors" + "fmt" "strings" "time" @@ -28,11 +29,13 @@ import ( ) // JWKSUrl is the URL of the JWKS used to verify App Check tokens. -var JWKSUrl = "https://firebaseappcheck.googleapis.com/v1beta/jwks" +var JWKSUrl = "https://firebaseappcheck.googleapis.com/v1/jwks" const appCheckIssuer = "https://firebaseappcheck.googleapis.com/" var ( + verifyURLFormat = "https://firebaseappcheck.googleapis.com/v1beta/projects/%s:verifyAppCheckToken" + // 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. @@ -50,22 +53,25 @@ var ( // 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. +// and ExpiresAt (exp). Additionally, it provides an AppID field, which indicates the application ID to which this +// token belongs, and an AlreadyConsumed field, which is populated when verifying a one-time token. +// 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{} + Issuer string + Subject string + Audience []string + ExpiresAt time.Time + IssuedAt time.Time + AppID string + AlreadyConsumed *bool + Claims map[string]interface{} } // Client is the interface for the Firebase App Check service. type Client struct { projectID string jwks *keyfunc.JWKS + client *internal.HTTPClient } // NewClient creates a new instance of the Firebase App Check Client. @@ -74,6 +80,9 @@ type Client struct { // 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. + if conf.ProjectID == "" { + return nil, errors.New("project ID is required to access Firebase App Check client") + } jwks, err := keyfunc.Get(JWKSUrl, keyfunc.Options{ Ctx: ctx, RefreshInterval: 6 * time.Hour, @@ -82,9 +91,18 @@ func NewClient(ctx context.Context, conf *internal.AppCheckConfig) (*Client, err return nil, err } + hc, _, err := internal.NewHTTPClient(ctx, conf.Opts...) + if err != nil { + return nil, err + } + hc.Opts = []internal.HTTPOption{ + internal.WithHeader("x-goog-api-client", internal.GetMetricsHeader(conf.Version)), + } + return &Client{ projectID: conf.ProjectID, jwks: jwks, + client: hc, }, nil } @@ -166,6 +184,48 @@ func (c *Client) VerifyToken(token string) (*DecodedAppCheckToken, error) { return &appCheckToken, nil } +// VerifyOneTimeToken verifies the given App Check token and consumes it. +// +// This method performs the same stateless verification as VerifyToken. In addition, it makes a +// network call to the Firebase App Check backend to mark the token as consumed, enabling the +// replay protection feature. +// +// VerifyOneTimeToken does not return an error for tokens that have already been consumed. +// Instead, the AlreadyConsumed field of the returned DecodedAppCheckToken is set to true. +// +// Tokens are only considered to be consumed if they are sent to the App Check backend by calling +// this method; other uses of the token, such as calling VerifyToken, do not consume it. +// +// This replay protection feature requires an additional network call to the App Check backend +// and forces your clients to obtain a fresh attestation from your chosen attestation providers. +// This can therefore negatively impact performance and can potentially deplete your attestation +// providers' quotas faster. We recommend that you use this feature only for protecting +// low volume, security critical, or expensive operations. +func (c *Client) VerifyOneTimeToken(ctx context.Context, token string) (*DecodedAppCheckToken, error) { + decodedToken, err := c.VerifyToken(token) + if err != nil { + return nil, err + } + + url := fmt.Sprintf(verifyURLFormat, c.projectID) + req := &internal.Request{ + Method: "POST", + URL: url, + Body: internal.NewJSONEntity(map[string]string{"app_check_token": token}), + } + + var result struct { + AlreadyConsumed bool `json:"alreadyConsumed"` + } + + if _, err := c.client.DoAndUnmarshal(ctx, req, &result); err != nil { + return nil, err + } + + decodedToken.AlreadyConsumed = &result.AlreadyConsumed + return decodedToken, nil +} + func contains(s []string, str string) bool { for _, v := range s { if v == str { diff --git a/appcheck/appcheck_test.go b/appcheck/appcheck_test.go index 6cd088c0..1df0ca00 100644 --- a/appcheck/appcheck_test.go +++ b/appcheck/appcheck_test.go @@ -15,8 +15,24 @@ import ( "firebase.google.com/go/v4/internal" "github.com/golang-jwt/jwt/v4" "github.com/google/go-cmp/cmp" + "google.golang.org/api/option" ) +type appCheckClaims struct { + Aud []string `json:"aud"` + jwt.RegisteredClaims +} + +func TestNewClientWithoutProjectID(t *testing.T) { + conf := &internal.AppCheckConfig{ + Opts: []option.ClientOption{option.WithoutAuthentication()}, + } + client, err := NewClient(context.Background(), conf) + if client != nil || err == nil { + t.Errorf("NewClient() = (%v, %v); want = (nil, error)", client, err) + } +} + func TestVerifyTokenHasValidClaims(t *testing.T) { ts, err := setupFakeJWKS() if err != nil { @@ -32,6 +48,7 @@ func TestVerifyTokenHasValidClaims(t *testing.T) { JWKSUrl = ts.URL conf := &internal.AppCheckConfig{ ProjectID: "project_id", + Opts: []option.ClientOption{option.WithoutAuthentication()}, } client, err := NewClient(context.Background(), conf) @@ -39,11 +56,6 @@ func TestVerifyTokenHasValidClaims(t *testing.T) { 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 @@ -178,6 +190,7 @@ func TestVerifyTokenMustExist(t *testing.T) { JWKSUrl = ts.URL conf := &internal.AppCheckConfig{ ProjectID: "project_id", + Opts: []option.ClientOption{option.WithoutAuthentication()}, } client, err := NewClient(context.Background(), conf) @@ -211,6 +224,7 @@ func TestVerifyTokenNotExpired(t *testing.T) { JWKSUrl = ts.URL conf := &internal.AppCheckConfig{ ProjectID: "project_id", + Opts: []option.ClientOption{option.WithoutAuthentication()}, } client, err := NewClient(context.Background(), conf) @@ -287,3 +301,111 @@ func loadPrivateKey() (*rsa.PrivateKey, error) { } return privateKey, nil } + +func TestVerifyOneTimeToken(t *testing.T) { + ts, err := setupFakeJWKS() + if err != nil { + t.Fatalf("Error setting up fake JWKS server: %v", err) + } + defer ts.Close() + + JWKSUrl = ts.URL + + privateKey, err := loadPrivateKey() + if err != nil { + t.Fatalf("Error loading private key: %v", err) + } + + mockTime := time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC) + jwt.TimeFunc = func() time.Time { + return mockTime + } + + claims := &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), + }, + } + jwtToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + jwtToken.Header["kid"] = "FGQdnRlzAmKyKr6-Hg_kMQrBkj_H6i6ADnBQz4OI6BU" + tokenString, err := jwtToken.SignedString(privateKey) + if err != nil { + t.Fatalf("Error signing token: %v", err) + } + + boolPtr := func(b bool) *bool { return &b } + + tests := []struct { + name string + backendResponse string + backendStatus int + wantAlreadyConsumed *bool + wantErr bool + }{ + { + name: "success_not_consumed", + backendResponse: `{"alreadyConsumed": false}`, + backendStatus: http.StatusOK, + wantAlreadyConsumed: boolPtr(false), + }, + { + name: "success_already_consumed", + backendResponse: `{"alreadyConsumed": true}`, + backendStatus: http.StatusOK, + wantAlreadyConsumed: boolPtr(true), + }, + { + name: "backend_error", + backendResponse: `{"error": {"message": "Internal Server Error"}}`, + backendStatus: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if h, want := r.Header.Get("x-goog-api-client"), internal.GetMetricsHeader("test-version"); h != want { + t.Errorf("x-goog-api-client header = %q; want = %q", h, want) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.backendStatus) + w.Write([]byte(tc.backendResponse)) + })) + defer backend.Close() + + oldVerifyURLFormat := verifyURLFormat + defer func() { verifyURLFormat = oldVerifyURLFormat }() + verifyURLFormat = backend.URL + "/v1beta/projects/%s:verifyAppCheckToken" + + conf := &internal.AppCheckConfig{ + ProjectID: "project_id", + Opts: []option.ClientOption{option.WithoutAuthentication()}, + Version: "test-version", + } + client, err := NewClient(context.Background(), conf) + if err != nil { + t.Fatalf("Error creating NewClient: %v", err) + } + + decodedToken, err := client.VerifyOneTimeToken(context.Background(), tokenString) + if tc.wantErr { + if err == nil { + t.Fatalf("Expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if decodedToken.AlreadyConsumed == nil || *decodedToken.AlreadyConsumed != *tc.wantAlreadyConsumed { + t.Errorf("VerifyOneTimeToken() AlreadyConsumed = %v; want = %v", decodedToken.AlreadyConsumed, tc.wantAlreadyConsumed) + } + }) + } +} diff --git a/firebase.go b/firebase.go index 3208ea38..e604ed22 100644 --- a/firebase.go +++ b/firebase.go @@ -144,6 +144,8 @@ func (a *App) Messaging(ctx context.Context) (*messaging.Client, error) { func (a *App) AppCheck(ctx context.Context) (*appcheck.Client, error) { conf := &internal.AppCheckConfig{ ProjectID: a.projectID, + Opts: a.opts, + Version: Version, } return appcheck.NewClient(ctx, conf) } diff --git a/internal/internal.go b/internal/internal.go index 8961c319..20922be2 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -84,6 +84,8 @@ type RemoteConfigClientConfig struct { // AppCheckConfig represents the configuration of App Check service. type AppCheckConfig struct { ProjectID string + Opts []option.ClientOption + Version string } // PhoneNumberVerificationConfig represents the configuration of Firebase Phone Number Verification service.