Skip to content
Open
Show file tree
Hide file tree
Changes from 17 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/v1beta/projects/%s:verifyAppCheckToken"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our v1 endpoint is almost ready, and that would be our preferred URL when this SDK is released. Maybe we can just change this to v1 now?

I'm also fine with leaving this alone for now and wait until our v1 endpoint is fully published. In that case, is a TODO appropriate here?

@yvonnep165 yvonnep165 Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick review! Sure, we can switch to the v1 endpoint as long as we can verify and run our tests against it. Currently, we only have unit tests in place so we can just change to v1 now, but we plan to add integration tests against the live endpoint once we implement the token creation methods.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing we should keep in mind if we do merge this as v1, is that our next release will include this whether or not the backend is ready. Lets set v1 but hold back merging until the backend is live.


// 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 + "/v1beta/projects/%s:verifyAppCheckToken"
Comment thread
yvonnep165 marked this conversation as resolved.
Outdated

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