Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ed9b1fb
Add replay protection feature
agbaraka Sep 8, 2024
b8487ba
refactor code and description
agbaraka Sep 9, 2024
6c999a4
refactor code and cleanup as per feedback
agbaraka Sep 10, 2024
604af8d
Refactor token verifier URL generation
agbaraka Sep 11, 2024
c746ccb
Merge branch 'dev' into feature/app-check-consume-endpoint
agbaraka Nov 30, 2025
97e1f4d
Refactor code and cleanup
agbaraka Nov 30, 2025
3946367
Revert package updates
agbaraka Dec 1, 2025
863183a
Fix linter errors
agbaraka Dec 1, 2025
7064317
Fix failed tests
agbaraka Dec 4, 2025
688d513
Refactor to include http failures test cases
agbaraka Dec 4, 2025
b09857b
Add one time token verification support
yvonnep165 Aug 10, 2026
d580ec0
Add unit tests
yvonnep165 Aug 10, 2026
8acd3c3
Fix formatting
yvonnep165 Aug 10, 2026
0efa019
Use a helper function boolPtr in tests
yvonnep165 Aug 10, 2026
99b64d1
Restore original value of verifyURLFormat using defer
yvonnep165 Aug 10, 2026
f1bbce2
Fix unit test error
yvonnep165 Aug 10, 2026
6d3c8b2
Merge PR #641 for credit attribution
yvonnep165 Aug 11, 2026
9de05fb
Update to v1 endpoint
yvonnep165 Aug 11, 2026
a357931
update the JWKSUrl to remove beta
yvonnep165 Aug 26, 2026
ee86e00
Merge branch 'dev' into yp-verify-one-time-token
yvonnep165 Aug 26, 2026
455635d
switch back to the v1beta endpoint for verifyAppCheckToken
yvonnep165 Sep 23, 2026
4c681fe
Merge branch 'dev' into yp-verify-one-time-token
yvonnep165 Sep 23, 2026
96f384b
Merge branch 'dev' into yp-verify-one-time-token
yvonnep165 Sep 23, 2026
07f03dc
Merge branch 'dev' into yp-verify-one-time-token
yvonnep165 Sep 30, 2026
6c20837
add usage headers and more detailed docstring for VerifyOneTimeToken
yvonnep165 Oct 7, 2026
c372167
add a check for ProjectID
yvonnep165 Oct 7, 2026
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
80 changes: 70 additions & 10 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 @@ -28,11 +29,13 @@ import (
)

// JWKSUrl is the URL of the JWKS used to verify App Check tokens.
var JWKSUrl = "https://firebaseappcheck.googleapis.com/v1beta/jwks"
var JWKSUrl = "https://firebaseappcheck.googleapis.com/v1/jwks"

const appCheckIssuer = "https://firebaseappcheck.googleapis.com/"

var (
verifyURLFormat = "https://firebaseappcheck.googleapis.com/v1beta/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 @@ -74,6 +80,9 @@ type Client struct {
// the App Check service through firebase.App.
func NewClient(ctx context.Context, conf *internal.AppCheckConfig) (*Client, error) {
// TODO: Add support for overriding the HTTP client using the App one.
if conf.ProjectID == "" {
return nil, errors.New("project ID is required to access Firebase App Check client")
}
Comment on lines +83 to +85

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.

medium

To prevent a potential nil pointer dereference, verify that conf is not nil before accessing its fields.

	if conf == nil {
		return nil, errors.New("appcheck config is required")
	}
	if conf.ProjectID == "" {
		return nil, errors.New("project ID is required to access Firebase App Check client")
	}

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.

NewClient is only called internally from App.AppCheck, which always passes a non-nil config, and internal.AppCheckConfig can't be constructed outside the module. This also matches the other service constructors, which don't nil-check their config either, so I'd prefer to keep it consistent.

jwks, err := keyfunc.Get(JWKSUrl, keyfunc.Options{
Ctx: ctx,
RefreshInterval: 6 * time.Hour,
Expand All @@ -82,9 +91,18 @@ 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
}
hc.Opts = []internal.HTTPOption{
internal.WithHeader("x-goog-api-client", internal.GetMetricsHeader(conf.Version)),
}
Comment on lines +98 to +100

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.

medium

Instead of overwriting hc.Opts, append to it. This ensures that any existing options configured on the client are preserved.

	hc.Opts = append(hc.Opts, internal.WithHeader("x-goog-api-client", internal.GetMetricsHeader(conf.Version)))

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.

hc is freshly created by internal.NewHTTPClient on the line above, which never populates Opts, so there's nothing to preserve and both forms behave identically.


Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit because I am unsure if we track this.
This is missing the usage headers

	hc.Opts = []internal.HTTPOption{
		internal.WithHeader("x-goog-api-client", internal.GetMetricsHeader(config.Version)),
	}

return &Client{
projectID: conf.ProjectID,
jwks: jwks,
client: hc,
}, nil
}

Expand Down Expand Up @@ -166,6 +184,48 @@ 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
// network call to the Firebase App Check backend to mark the token as consumed, enabling the
// replay protection feature.
//
// VerifyOneTimeToken does not return an error for tokens that have already been consumed.
// Instead, the AlreadyConsumed field of the returned DecodedAppCheckToken is set to true.
//
// Tokens are only considered to be consumed if they are sent to the App Check backend by calling
// this method; other uses of the token, such as calling VerifyToken, do not consume it.
//
// This replay protection feature requires an additional network call to the App Check backend
// and forces your clients to obtain a fresh attestation from your chosen attestation providers.
// This can therefore negatively impact performance and can potentially deplete your attestation
// providers' quotas faster. We recommend that you use this feature only for protecting
// low volume, security critical, or expensive operations.
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
132 changes: 127 additions & 5 deletions appcheck/appcheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,24 @@ 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 TestNewClientWithoutProjectID(t *testing.T) {
conf := &internal.AppCheckConfig{
Opts: []option.ClientOption{option.WithoutAuthentication()},
}
client, err := NewClient(context.Background(), conf)
if client != nil || err == nil {
t.Errorf("NewClient() = (%v, %v); want = (nil, error)", client, err)
}
}

func TestVerifyTokenHasValidClaims(t *testing.T) {
ts, err := setupFakeJWKS()
if err != nil {
Expand All @@ -32,18 +48,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 +190,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 +224,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 +301,111 @@ 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.
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) {
if h, want := r.Header.Get("x-goog-api-client"), internal.GetMetricsHeader("test-version"); h != want {
t.Errorf("x-goog-api-client header = %q; want = %q", h, want)
}
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"

conf := &internal.AppCheckConfig{
ProjectID: "project_id",
Opts: []option.ClientOption{option.WithoutAuthentication()},
Version: "test-version",
}
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)
}
})
}
}
2 changes: 2 additions & 0 deletions firebase.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ 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,
Version: Version,
}
return appcheck.NewClient(ctx, conf)
}
Expand Down
2 changes: 2 additions & 0 deletions internal/internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ type RemoteConfigClientConfig struct {
// AppCheckConfig represents the configuration of App Check service.
type AppCheckConfig struct {
ProjectID string
Opts []option.ClientOption
Version string
}

// PhoneNumberVerificationConfig represents the configuration of Firebase Phone Number Verification service.
Expand Down
Loading