diff --git a/appcheck/appcheck.go b/appcheck/appcheck.go index 89868916..0bb324b1 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" @@ -32,6 +36,8 @@ var JWKSUrl = "https://firebaseappcheck.googleapis.com/v1beta/jwks" const appCheckIssuer = "https://firebaseappcheck.googleapis.com/" +const tokenVerifierBaseUrl = "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") @@ -45,6 +51,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 +72,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 + tokenVerifierUrl string } // NewClient creates a new instance of the Firebase App Check Client. @@ -83,8 +92,9 @@ func NewClient(ctx context.Context, conf *internal.AppCheckConfig) (*Client, err } return &Client{ - projectID: conf.ProjectID, - jwks: jwks, + projectID: conf.ProjectID, + jwks: jwks, + tokenVerifierUrl: buildTokenVerifierUrl(conf.ProjectID), }, nil } @@ -166,6 +176,69 @@ func (c *Client) VerifyToken(token string) (*DecodedAppCheckToken, error) { return &appCheckToken, nil } +// 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 { + return nil, err + } + + bodyReader := bytes.NewReader([]byte(fmt.Sprintf(`{"app_check_token":%s}`, token))) + + resp, err := http.Post(c.tokenVerifierUrl, "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 nil, ErrTokenAlreadyConsumed + } + + 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 6cd088c0..e984956a 100644 --- a/appcheck/appcheck_test.go +++ b/appcheck/appcheck_test.go @@ -17,6 +17,80 @@ import ( "github.com/google/go-cmp/cmp" ) +func TestVerifyOneTimeToken(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/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)), + }) + + // 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.tokenVerifierUrl = appCheckVerifyMockServer.URL + + _, err = client.VerifyOneTimeToken(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 {