From ed9b1fb9944c4f9b294698ad66011989422639c5 Mon Sep 17 00:00:00 2001 From: Albert Shirima Date: Sun, 8 Sep 2024 15:00:36 +0300 Subject: [PATCH 1/4] Add replay protection feature --- appcheck/appcheck.go | 48 ++++++++++++++++++++++--- appcheck/appcheck_test.go | 74 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/appcheck/appcheck.go b/appcheck/appcheck.go index 89868916..a1ef157e 100644 --- a/appcheck/appcheck.go +++ b/appcheck/appcheck.go @@ -16,8 +16,12 @@ package appcheck import ( + "bytes" "context" + "encoding/json" "errors" + "fmt" + "net/http" "strings" "time" @@ -45,6 +49,8 @@ var ( 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") + // ErrTokenAlreadyConsumed is returned when the token is already consumed + ErrTokenAlreadyConsumed = errors.New("token already consumed") ) // DecodedAppCheckToken represents a verified App Check token. @@ -64,8 +70,9 @@ type DecodedAppCheckToken struct { // Client is the interface for the Firebase App Check service. type Client struct { - projectID string - jwks *keyfunc.JWKS + projectID string + jwks *keyfunc.JWKS + verifyAppCheckTokenURL string } // NewClient creates a new instance of the Firebase App Check Client. @@ -83,8 +90,9 @@ func NewClient(ctx context.Context, conf *internal.AppCheckConfig) (*Client, err } return &Client{ - projectID: conf.ProjectID, - jwks: jwks, + projectID: conf.ProjectID, + jwks: jwks, + verifyAppCheckTokenURL: fmt.Sprintf("%sv1beta/projects/%s:verifyAppCheckToken", appCheckIssuer, conf.ProjectID), }, nil } @@ -166,6 +174,38 @@ func (c *Client) VerifyToken(token string) (*DecodedAppCheckToken, error) { return &appCheckToken, nil } +func (c *Client) VerifyTokenWithReplayProtection(token string) (*DecodedAppCheckToken, error) { + decodedAppCheckToken, err := c.VerifyToken(token) + + if err != nil { + return nil, fmt.Errorf("failed to verify token: %v", err) + } + + bodyReader := bytes.NewReader([]byte(fmt.Sprintf(`{"app_check_token":%s}`, token))) + + resp, err := http.Post(c.verifyAppCheckTokenURL, "application/json", bodyReader) + + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + var rb struct { + AlreadyConsumed bool `json:"alreadyConsumed"` + } + + if err := json.NewDecoder(resp.Body).Decode(&rb); err != nil { + return nil, err + } + + if rb.AlreadyConsumed { + return decodedAppCheckToken, ErrTokenAlreadyConsumed + } + + return decodedAppCheckToken, 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..e7a085a9 100644 --- a/appcheck/appcheck_test.go +++ b/appcheck/appcheck_test.go @@ -17,6 +17,80 @@ import ( "github.com/google/go-cmp/cmp" ) +func TestVerifyTokenWithReplayProtection(t *testing.T) { + + projectID := "project_id" + + 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 + mockTime := time.Now() + + jwtToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.RegisteredClaims{ + Issuer: appCheckIssuer, + Audience: jwt.ClaimStrings([]string{"projects/" + projectID}), + Subject: "12345678:app:ID", + ExpiresAt: jwt.NewNumericDate(mockTime.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(mockTime), + NotBefore: jwt.NewNumericDate(mockTime.Add(-1 * time.Hour)), + }) + + // 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("failed to sign token: %v", err) + } + + appCheckVerifyTestsTable := []struct { + label string + mockServerResponse string + expectedError error + }{ + {label: "testWhenAlreadyConsumedResponseIsTrue", mockServerResponse: `{"alreadyConsumed": true}`, expectedError: ErrTokenAlreadyConsumed}, + {label: "testWhenAlreadyConsumedResponseIsFalse", mockServerResponse: `{"alreadyConsumed": false}`, expectedError: nil}, + } + + for _, tt := range appCheckVerifyTestsTable { + + t.Run(tt.label, func(t *testing.T) { + appCheckVerifyMockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(tt.mockServerResponse)) + })) + + client, err := NewClient(context.Background(), &internal.AppCheckConfig{ + ProjectID: projectID, + }) + + if err != nil { + t.Fatalf("error creating new client: %v", err) + } + + client.verifyAppCheckTokenURL = appCheckVerifyMockServer.URL + + _, err = client.VerifyTokenWithReplayProtection(token) + + if !errors.Is(err, tt.expectedError) { + t.Errorf("failed to verify token; Expected: %v, but got: %v", tt.expectedError, err) + } + }) + + } +} + func TestVerifyTokenHasValidClaims(t *testing.T) { ts, err := setupFakeJWKS() if err != nil { From b8487baf7b249f0f8e0ab5d1a81c8a3e430070d3 Mon Sep 17 00:00:00 2001 From: Albert Shirima Date: Mon, 9 Sep 2024 22:39:09 +0300 Subject: [PATCH 2/4] refactor code and description --- appcheck/appcheck.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/appcheck/appcheck.go b/appcheck/appcheck.go index a1ef157e..69129c3e 100644 --- a/appcheck/appcheck.go +++ b/appcheck/appcheck.go @@ -174,11 +174,14 @@ func (c *Client) VerifyToken(token string) (*DecodedAppCheckToken, error) { return &appCheckToken, nil } +// VerifyTokenWithReplayProtection checks the given App Check token as follows: +// - Uses VerifyToken to validate the given token as described. if verification failed, appropriate error will be returned. +// - Checks if the token token has been consumed. if already consumed the pointer to decoded token is returned with ErrTokenAlreadyConsumed. func (c *Client) VerifyTokenWithReplayProtection(token string) (*DecodedAppCheckToken, error) { decodedAppCheckToken, err := c.VerifyToken(token) if err != nil { - return nil, fmt.Errorf("failed to verify token: %v", err) + return nil, err } bodyReader := bytes.NewReader([]byte(fmt.Sprintf(`{"app_check_token":%s}`, token))) From 6c999a4f18c70a609651d60b0e318dc5100ce7b5 Mon Sep 17 00:00:00 2001 From: Albert Shirima Date: Tue, 10 Sep 2024 21:13:08 +0300 Subject: [PATCH 3/4] refactor code and cleanup as per feedback --- appcheck/appcheck.go | 50 +++++++++++++++++++++++++++++---------- appcheck/appcheck_test.go | 10 ++++---- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/appcheck/appcheck.go b/appcheck/appcheck.go index 69129c3e..7323758b 100644 --- a/appcheck/appcheck.go +++ b/appcheck/appcheck.go @@ -36,6 +36,8 @@ var JWKSUrl = "https://firebaseappcheck.googleapis.com/v1beta/jwks" const appCheckIssuer = "https://firebaseappcheck.googleapis.com/" +const tokenVerificationUrlFormat = "https://firebaseappcheck.googleapis.com/v1beta/projects/%s:verifyAppCheckToken" + var ( // ErrIncorrectAlgorithm is returned when the token is signed with a non-RSA256 algorithm. ErrIncorrectAlgorithm = errors.New("token has incorrect algorithm") @@ -70,9 +72,9 @@ type DecodedAppCheckToken struct { // Client is the interface for the Firebase App Check service. type Client struct { - projectID string - jwks *keyfunc.JWKS - verifyAppCheckTokenURL string + projectID string + jwks *keyfunc.JWKS + tokenVerificationUrl string } // NewClient creates a new instance of the Firebase App Check Client. @@ -90,9 +92,9 @@ func NewClient(ctx context.Context, conf *internal.AppCheckConfig) (*Client, err } return &Client{ - projectID: conf.ProjectID, - jwks: jwks, - verifyAppCheckTokenURL: fmt.Sprintf("%sv1beta/projects/%s:verifyAppCheckToken", appCheckIssuer, conf.ProjectID), + projectID: conf.ProjectID, + jwks: jwks, + tokenVerificationUrl: fmt.Sprintf(tokenVerificationUrlFormat, conf.ProjectID), }, nil } @@ -174,10 +176,34 @@ func (c *Client) VerifyToken(token string) (*DecodedAppCheckToken, error) { return &appCheckToken, nil } -// VerifyTokenWithReplayProtection checks the given App Check token as follows: -// - Uses VerifyToken to validate the given token as described. if verification failed, appropriate error will be returned. -// - Checks if the token token has been consumed. if already consumed the pointer to decoded token is returned with ErrTokenAlreadyConsumed. -func (c *Client) VerifyTokenWithReplayProtection(token string) (*DecodedAppCheckToken, error) { +// VerifyOneTimeToken verifies the given App Check token and consumes it, so that it cannot be consumed again. +// +// VerifyOneTimeToken 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, regardless whether the token was +// previously consumed or not. +// +// This method currently only supports App Check tokens exchanged from the following attestation +// providers: +// +// - Play Integrity API +// - Apple App Attest +// - Apple DeviceCheck (DCDevice tokens) +// - reCAPTCHA Enterprise +// - reCAPTCHA v3 +// - Custom providers +// +// App Check tokens exchanged from debug secrets are also supported. Calling this method on an +// otherwise valid App Check token with an unsupported provider will cause an error to be returned. +// +// If the token was already consumed prior to this call, an error is returned. +func (c *Client) VerifyOneTimeToken(token string) (*DecodedAppCheckToken, error) { decodedAppCheckToken, err := c.VerifyToken(token) if err != nil { @@ -186,7 +212,7 @@ func (c *Client) VerifyTokenWithReplayProtection(token string) (*DecodedAppCheck bodyReader := bytes.NewReader([]byte(fmt.Sprintf(`{"app_check_token":%s}`, token))) - resp, err := http.Post(c.verifyAppCheckTokenURL, "application/json", bodyReader) + resp, err := http.Post(c.tokenVerificationUrl, "application/json", bodyReader) if err != nil { return nil, err @@ -203,7 +229,7 @@ func (c *Client) VerifyTokenWithReplayProtection(token string) (*DecodedAppCheck } if rb.AlreadyConsumed { - return decodedAppCheckToken, ErrTokenAlreadyConsumed + return nil, ErrTokenAlreadyConsumed } return decodedAppCheckToken, nil diff --git a/appcheck/appcheck_test.go b/appcheck/appcheck_test.go index e7a085a9..9fe4f6c7 100644 --- a/appcheck/appcheck_test.go +++ b/appcheck/appcheck_test.go @@ -17,7 +17,7 @@ import ( "github.com/google/go-cmp/cmp" ) -func TestVerifyTokenWithReplayProtection(t *testing.T) { +func TestVerifyOneTimeToken(t *testing.T) { projectID := "project_id" @@ -37,8 +37,8 @@ func TestVerifyTokenWithReplayProtection(t *testing.T) { jwtToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.RegisteredClaims{ Issuer: appCheckIssuer, - Audience: jwt.ClaimStrings([]string{"projects/" + projectID}), - Subject: "12345678:app:ID", + Audience: jwt.ClaimStrings([]string{"projects/12345678", "projects/" + projectID}), + Subject: "1:12345678:android:abcdef", ExpiresAt: jwt.NewNumericDate(mockTime.Add(time.Hour)), IssuedAt: jwt.NewNumericDate(mockTime), NotBefore: jwt.NewNumericDate(mockTime.Add(-1 * time.Hour)), @@ -79,9 +79,9 @@ func TestVerifyTokenWithReplayProtection(t *testing.T) { t.Fatalf("error creating new client: %v", err) } - client.verifyAppCheckTokenURL = appCheckVerifyMockServer.URL + client.tokenVerificationUrl = appCheckVerifyMockServer.URL - _, err = client.VerifyTokenWithReplayProtection(token) + _, err = client.VerifyOneTimeToken(token) if !errors.Is(err, tt.expectedError) { t.Errorf("failed to verify token; Expected: %v, but got: %v", tt.expectedError, err) From 604af8d6b510c3c4eec91af058e26dae2fb00705 Mon Sep 17 00:00:00 2001 From: Albert Shirima Date: Wed, 11 Sep 2024 17:49:00 +0300 Subject: [PATCH 4/4] Refactor token verifier URL generation --- appcheck/appcheck.go | 20 ++++++++++++-------- appcheck/appcheck_test.go | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/appcheck/appcheck.go b/appcheck/appcheck.go index 7323758b..0bb324b1 100644 --- a/appcheck/appcheck.go +++ b/appcheck/appcheck.go @@ -36,7 +36,7 @@ var JWKSUrl = "https://firebaseappcheck.googleapis.com/v1beta/jwks" const appCheckIssuer = "https://firebaseappcheck.googleapis.com/" -const tokenVerificationUrlFormat = "https://firebaseappcheck.googleapis.com/v1beta/projects/%s:verifyAppCheckToken" +const tokenVerifierBaseUrl = "https://firebaseappcheck.googleapis.com" var ( // ErrIncorrectAlgorithm is returned when the token is signed with a non-RSA256 algorithm. @@ -72,9 +72,9 @@ type DecodedAppCheckToken struct { // Client is the interface for the Firebase App Check service. type Client struct { - projectID string - jwks *keyfunc.JWKS - tokenVerificationUrl string + projectID string + jwks *keyfunc.JWKS + tokenVerifierUrl string } // NewClient creates a new instance of the Firebase App Check Client. @@ -92,9 +92,9 @@ func NewClient(ctx context.Context, conf *internal.AppCheckConfig) (*Client, err } return &Client{ - projectID: conf.ProjectID, - jwks: jwks, - tokenVerificationUrl: fmt.Sprintf(tokenVerificationUrlFormat, conf.ProjectID), + projectID: conf.ProjectID, + jwks: jwks, + tokenVerifierUrl: buildTokenVerifierUrl(conf.ProjectID), }, nil } @@ -212,7 +212,7 @@ func (c *Client) VerifyOneTimeToken(token string) (*DecodedAppCheckToken, error) bodyReader := bytes.NewReader([]byte(fmt.Sprintf(`{"app_check_token":%s}`, token))) - resp, err := http.Post(c.tokenVerificationUrl, "application/json", bodyReader) + resp, err := http.Post(c.tokenVerifierUrl, "application/json", bodyReader) if err != nil { return nil, err @@ -235,6 +235,10 @@ func (c *Client) VerifyOneTimeToken(token string) (*DecodedAppCheckToken, error) return decodedAppCheckToken, nil } +func buildTokenVerifierUrl(projectId string) string { + return fmt.Sprintf("%s/v1beta/projects/%s:verifyAppCheckToken", tokenVerifierBaseUrl, projectId) +} + 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 9fe4f6c7..e984956a 100644 --- a/appcheck/appcheck_test.go +++ b/appcheck/appcheck_test.go @@ -79,7 +79,7 @@ func TestVerifyOneTimeToken(t *testing.T) { t.Fatalf("error creating new client: %v", err) } - client.tokenVerificationUrl = appCheckVerifyMockServer.URL + client.tokenVerifierUrl = appCheckVerifyMockServer.URL _, err = client.VerifyOneTimeToken(token)