diff --git a/appcheck/appcheck.go b/appcheck/appcheck.go index c2fefaa8..9833d36c 100644 --- a/appcheck/appcheck.go +++ b/appcheck/appcheck.go @@ -18,6 +18,7 @@ package appcheck import ( "context" "errors" + "fmt" "strings" "time" @@ -32,6 +33,8 @@ var JWKSUrl = "https://firebaseappcheck.googleapis.com/v1beta/jwks" const appCheckIssuer = "https://firebaseappcheck.googleapis.com/" +var verifyTokenURL = "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") @@ -45,6 +48,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 +69,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 + httpClient *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, + projectID: conf.ProjectID, + jwks: jwks, + httpClient: hc, }, nil } @@ -166,6 +178,63 @@ 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(ctx context.Context, token string) (*DecodedAppCheckToken, error) { + decodedAppCheckToken, err := c.VerifyToken(token) + + if err != nil { + return nil, err + } + + req := &internal.Request{ + Method: "POST", + URL: fmt.Sprintf(verifyTokenURL, c.projectID), + Body: internal.NewJSONEntity(map[string]string{ + "app_check_token": token, + }), + } + + var resp struct { + AlreadyConsumed bool `json:"alreadyConsumed"` + } + + if _, err := c.httpClient.DoAndUnmarshal(ctx, req, &resp); err != nil { + return nil, err + } + + if resp.AlreadyConsumed { + return nil, 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..0ef1c503 100644 --- a/appcheck/appcheck_test.go +++ b/appcheck/appcheck_test.go @@ -1,22 +1,135 @@ package appcheck import ( + "bytes" "context" "crypto/rsa" "crypto/x509" "encoding/pem" "errors" + "io" "net/http" "net/http/httptest" "os" + "strings" "testing" "time" "firebase.google.com/go/v4/internal" "github.com/golang-jwt/jwt/v4" "github.com/google/go-cmp/cmp" + "google.golang.org/api/option" ) +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 + expectedError error + mockHTTPTransport mockHTTPTransport + }{ + { + label: "testWhenAlreadyConsumedResponseIsTrue", + expectedError: ErrTokenAlreadyConsumed, + mockHTTPTransport: mockHTTPTransport{ + Response: http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"alreadyConsumed": true}`)), + }, + Err: nil, + }, + }, + { + label: "testWhenAlreadyConsumedResponseIsFalse", + expectedError: nil, + mockHTTPTransport: mockHTTPTransport{ + Response: http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"alreadyConsumed": false}`)), + }, + Err: nil, + }, + }, + { + label: "testWhenTokenCheckResponseReturnsError", + expectedError: internal.NewFirebaseError(&internal.Response{Status: 500}), + mockHTTPTransport: mockHTTPTransport{ + Response: http.Response{ + StatusCode: 500, + Body: http.NoBody, + }, + Err: internal.NewFirebaseError(&internal.Response{Status: 500}), + }, + }, + } + + for _, tt := range appCheckVerifyTestsTable { + + t.Run(tt.label, func(t *testing.T) { + + mockHTTPClient := &http.Client{ + Transport: &tt.mockHTTPTransport, + } + + conf := &internal.AppCheckConfig{ + ProjectID: projectID, + Opts: []option.ClientOption{ + option.WithHTTPClient(mockHTTPClient), + }, + } + + client, err := NewClient(context.Background(), conf) + + if err != nil { + t.Fatalf("error creating new client: %v", err) + } + + _, gotErr := client.VerifyOneTimeToken(context.Background(), token) + + if gotErr != nil && !strings.HasSuffix(gotErr.Error(), tt.expectedError.Error()) { + t.Errorf("Expected error: %v, but got: %v", tt.expectedError, gotErr) + } + }) + + } +} + func TestVerifyTokenHasValidClaims(t *testing.T) { ts, err := setupFakeJWKS() if err != nil { @@ -32,6 +145,9 @@ func TestVerifyTokenHasValidClaims(t *testing.T) { JWKSUrl = ts.URL conf := &internal.AppCheckConfig{ ProjectID: "project_id", + Opts: []option.ClientOption{ + option.WithHTTPClient(ts.Client()), + }, } client, err := NewClient(context.Background(), conf) @@ -178,6 +294,9 @@ func TestVerifyTokenMustExist(t *testing.T) { JWKSUrl = ts.URL conf := &internal.AppCheckConfig{ ProjectID: "project_id", + Opts: []option.ClientOption{ + option.WithHTTPClient(ts.Client()), + }, } client, err := NewClient(context.Background(), conf) @@ -211,6 +330,9 @@ func TestVerifyTokenNotExpired(t *testing.T) { JWKSUrl = ts.URL conf := &internal.AppCheckConfig{ ProjectID: "project_id", + Opts: []option.ClientOption{ + option.WithHTTPClient(ts.Client()), + }, } client, err := NewClient(context.Background(), conf) @@ -287,3 +409,12 @@ func loadPrivateKey() (*rsa.PrivateKey, error) { } return privateKey, nil } + +type mockHTTPTransport struct { + Response http.Response + Err error +} + +func (m *mockHTTPTransport) RoundTrip(*http.Request) (*http.Response, error) { + return &m.Response, m.Err +} diff --git a/firebase.go b/firebase.go index 9373ae23..675d7e51 100644 --- a/firebase.go +++ b/firebase.go @@ -135,6 +135,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 58450f45..e777bd68 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 } // MockTokenSource is a TokenSource implementation that can be used for testing.