Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
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
62 changes: 52 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 @@ -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"

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 @@ -144,6 +144,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