Skip to content

Commit ed9b1fb

Browse files
committed
Add replay protection feature
1 parent 911c7e8 commit ed9b1fb

File tree

2 files changed

+118
-4
lines changed

2 files changed

+118
-4
lines changed

appcheck/appcheck.go

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616
package appcheck
1717

1818
import (
19+
"bytes"
1920
"context"
21+
"encoding/json"
2022
"errors"
23+
"fmt"
24+
"net/http"
2125
"strings"
2226
"time"
2327

@@ -45,6 +49,8 @@ var (
4549
ErrTokenIssuer = errors.New("token has incorrect issuer")
4650
// ErrTokenSubject is returned when the token subject is empty or missing.
4751
ErrTokenSubject = errors.New("token has empty or missing subject")
52+
// ErrTokenAlreadyConsumed is returned when the token is already consumed
53+
ErrTokenAlreadyConsumed = errors.New("token already consumed")
4854
)
4955

5056
// DecodedAppCheckToken represents a verified App Check token.
@@ -64,8 +70,9 @@ type DecodedAppCheckToken struct {
6470

6571
// Client is the interface for the Firebase App Check service.
6672
type Client struct {
67-
projectID string
68-
jwks *keyfunc.JWKS
73+
projectID string
74+
jwks *keyfunc.JWKS
75+
verifyAppCheckTokenURL string
6976
}
7077

7178
// NewClient creates a new instance of the Firebase App Check Client.
@@ -83,8 +90,9 @@ func NewClient(ctx context.Context, conf *internal.AppCheckConfig) (*Client, err
8390
}
8491

8592
return &Client{
86-
projectID: conf.ProjectID,
87-
jwks: jwks,
93+
projectID: conf.ProjectID,
94+
jwks: jwks,
95+
verifyAppCheckTokenURL: fmt.Sprintf("%sv1beta/projects/%s:verifyAppCheckToken", appCheckIssuer, conf.ProjectID),
8896
}, nil
8997
}
9098

@@ -166,6 +174,38 @@ func (c *Client) VerifyToken(token string) (*DecodedAppCheckToken, error) {
166174
return &appCheckToken, nil
167175
}
168176

177+
func (c *Client) VerifyTokenWithReplayProtection(token string) (*DecodedAppCheckToken, error) {
178+
decodedAppCheckToken, err := c.VerifyToken(token)
179+
180+
if err != nil {
181+
return nil, fmt.Errorf("failed to verify token: %v", err)
182+
}
183+
184+
bodyReader := bytes.NewReader([]byte(fmt.Sprintf(`{"app_check_token":%s}`, token)))
185+
186+
resp, err := http.Post(c.verifyAppCheckTokenURL, "application/json", bodyReader)
187+
188+
if err != nil {
189+
return nil, err
190+
}
191+
192+
defer resp.Body.Close()
193+
194+
var rb struct {
195+
AlreadyConsumed bool `json:"alreadyConsumed"`
196+
}
197+
198+
if err := json.NewDecoder(resp.Body).Decode(&rb); err != nil {
199+
return nil, err
200+
}
201+
202+
if rb.AlreadyConsumed {
203+
return decodedAppCheckToken, ErrTokenAlreadyConsumed
204+
}
205+
206+
return decodedAppCheckToken, nil
207+
}
208+
169209
func contains(s []string, str string) bool {
170210
for _, v := range s {
171211
if v == str {

appcheck/appcheck_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,80 @@ import (
1717
"github.com/google/go-cmp/cmp"
1818
)
1919

20+
func TestVerifyTokenWithReplayProtection(t *testing.T) {
21+
22+
projectID := "project_id"
23+
24+
ts, err := setupFakeJWKS()
25+
if err != nil {
26+
t.Fatalf("error setting up fake JWKS server: %v", err)
27+
}
28+
defer ts.Close()
29+
30+
privateKey, err := loadPrivateKey()
31+
if err != nil {
32+
t.Fatalf("error loading private key: %v", err)
33+
}
34+
35+
JWKSUrl = ts.URL
36+
mockTime := time.Now()
37+
38+
jwtToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.RegisteredClaims{
39+
Issuer: appCheckIssuer,
40+
Audience: jwt.ClaimStrings([]string{"projects/" + projectID}),
41+
Subject: "12345678:app:ID",
42+
ExpiresAt: jwt.NewNumericDate(mockTime.Add(time.Hour)),
43+
IssuedAt: jwt.NewNumericDate(mockTime),
44+
NotBefore: jwt.NewNumericDate(mockTime.Add(-1 * time.Hour)),
45+
})
46+
47+
// kid matches the key ID in testdata/mock.jwks.json,
48+
// which is the public key matching to the private key
49+
// in testdata/appcheck_pk.pem.
50+
jwtToken.Header["kid"] = "FGQdnRlzAmKyKr6-Hg_kMQrBkj_H6i6ADnBQz4OI6BU"
51+
52+
token, err := jwtToken.SignedString(privateKey)
53+
54+
if err != nil {
55+
t.Fatalf("failed to sign token: %v", err)
56+
}
57+
58+
appCheckVerifyTestsTable := []struct {
59+
label string
60+
mockServerResponse string
61+
expectedError error
62+
}{
63+
{label: "testWhenAlreadyConsumedResponseIsTrue", mockServerResponse: `{"alreadyConsumed": true}`, expectedError: ErrTokenAlreadyConsumed},
64+
{label: "testWhenAlreadyConsumedResponseIsFalse", mockServerResponse: `{"alreadyConsumed": false}`, expectedError: nil},
65+
}
66+
67+
for _, tt := range appCheckVerifyTestsTable {
68+
69+
t.Run(tt.label, func(t *testing.T) {
70+
appCheckVerifyMockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71+
w.Write([]byte(tt.mockServerResponse))
72+
}))
73+
74+
client, err := NewClient(context.Background(), &internal.AppCheckConfig{
75+
ProjectID: projectID,
76+
})
77+
78+
if err != nil {
79+
t.Fatalf("error creating new client: %v", err)
80+
}
81+
82+
client.verifyAppCheckTokenURL = appCheckVerifyMockServer.URL
83+
84+
_, err = client.VerifyTokenWithReplayProtection(token)
85+
86+
if !errors.Is(err, tt.expectedError) {
87+
t.Errorf("failed to verify token; Expected: %v, but got: %v", tt.expectedError, err)
88+
}
89+
})
90+
91+
}
92+
}
93+
2094
func TestVerifyTokenHasValidClaims(t *testing.T) {
2195
ts, err := setupFakeJWKS()
2296
if err != nil {

0 commit comments

Comments
 (0)