diff --git a/appcheck/appcheck.go b/appcheck/appcheck.go index c2fefaa8..fed425ab 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/v1/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. @@ -82,9 +88,15 @@ 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 + } + return &Client{ projectID: conf.ProjectID, jwks: jwks, + client: hc, }, nil } @@ -166,6 +178,36 @@ 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 +// stateful network call to the Firebase App Check backend to ensure that the token has not been +// consumed previously. If the token is valid, it is marked as consumed. +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..0d6d9edb 100644 --- a/appcheck/appcheck_test.go +++ b/appcheck/appcheck_test.go @@ -15,8 +15,14 @@ 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 TestVerifyTokenHasValidClaims(t *testing.T) { ts, err := setupFakeJWKS() if err != nil { @@ -32,6 +38,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 +46,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 +180,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 +214,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 +291,107 @@ 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) { + 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 + "/v1/projects/%s:verifyAppCheckToken" + + conf := &internal.AppCheckConfig{ + ProjectID: "project_id", + Opts: []option.ClientOption{option.WithoutAuthentication()}, + } + 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 d974d4b2..f5450d29 100644 --- a/firebase.go +++ b/firebase.go @@ -144,6 +144,7 @@ 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, } return appcheck.NewClient(ctx, conf) } diff --git a/internal/internal.go b/internal/internal.go index 8961c319..56403c5c 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -84,6 +84,7 @@ type RemoteConfigClientConfig struct { // AppCheckConfig represents the configuration of App Check service. type AppCheckConfig struct { ProjectID string + Opts []option.ClientOption } // PhoneNumberVerificationConfig represents the configuration of Firebase Phone Number Verification service.