Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 51 additions & 9 deletions appcheck/appcheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package appcheck
import (
"context"
"errors"
"fmt"
"strings"
"time"

Expand All @@ -33,6 +34,8 @@ var JWKSUrl = "https://firebaseappcheck.googleapis.com/v1beta/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.
Expand All @@ -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.
Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down
118 changes: 113 additions & 5 deletions appcheck/appcheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -32,18 +38,14 @@ 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)
if err != nil {
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
},
Comment thread
yvonnep165 marked this conversation as resolved.
{
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)
}
})
}
}
1 change: 1 addition & 0 deletions firebase.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,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)
}
Expand Down
1 change: 1 addition & 0 deletions internal/internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading