Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add replay protection feature #641

Open
wants to merge 4 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
81 changes: 77 additions & 4 deletions appcheck/appcheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@
package appcheck

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"

Expand All @@ -32,6 +36,8 @@ var JWKSUrl = "https://firebaseappcheck.googleapis.com/v1beta/jwks"

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

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

var (
// ErrIncorrectAlgorithm is returned when the token is signed with a non-RSA256 algorithm.
ErrIncorrectAlgorithm = errors.New("token has incorrect algorithm")
Expand All @@ -45,6 +51,8 @@ var (
ErrTokenIssuer = errors.New("token has incorrect issuer")
// ErrTokenSubject is returned when the token subject is empty or missing.
ErrTokenSubject = errors.New("token has empty or missing subject")
// ErrTokenAlreadyConsumed is returned when the token is already consumed
ErrTokenAlreadyConsumed = errors.New("token already consumed")
)

// DecodedAppCheckToken represents a verified App Check token.
Expand All @@ -64,8 +72,9 @@ type DecodedAppCheckToken struct {

// Client is the interface for the Firebase App Check service.
type Client struct {
projectID string
jwks *keyfunc.JWKS
projectID string
jwks *keyfunc.JWKS
tokenVerifierUrl string
}

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

return &Client{
projectID: conf.ProjectID,
jwks: jwks,
projectID: conf.ProjectID,
jwks: jwks,
tokenVerifierUrl: buildTokenVerifierUrl(conf.ProjectID),
}, nil
}

Expand Down Expand Up @@ -166,6 +176,69 @@ func (c *Client) VerifyToken(token string) (*DecodedAppCheckToken, error) {
return &appCheckToken, nil
}

// VerifyOneTimeToken verifies the given App Check token and consumes it, so that it cannot be consumed again.
//
// VerifyOneTimeToken considers an App Check token string to be valid if all the following conditions are met:
// - The token string is a valid RS256 JWT.
// - The JWT contains valid issuer (iss) and audience (aud) claims that match the issuerPrefix
// and projectID of the tokenVerifier.
// - The JWT contains a valid subject (sub) claim.
// - The JWT is not expired, and it has been issued some time in the past.
// - The JWT is signed by a Firebase App Check backend server as determined by the keySource.
//
// If any of the above conditions are not met, an error is returned, regardless whether the token was
// previously consumed or not.
//
// This method currently only supports App Check tokens exchanged from the following attestation
// providers:
//
// - Play Integrity API
// - Apple App Attest
// - Apple DeviceCheck (DCDevice tokens)
// - reCAPTCHA Enterprise
// - reCAPTCHA v3
// - Custom providers
//
// App Check tokens exchanged from debug secrets are also supported. Calling this method on an
// otherwise valid App Check token with an unsupported provider will cause an error to be returned.
//
// If the token was already consumed prior to this call, an error is returned.
func (c *Client) VerifyOneTimeToken(token string) (*DecodedAppCheckToken, error) {
decodedAppCheckToken, err := c.VerifyToken(token)

if err != nil {
return nil, err
}

bodyReader := bytes.NewReader([]byte(fmt.Sprintf(`{"app_check_token":%s}`, token)))

resp, err := http.Post(c.tokenVerifierUrl, "application/json", bodyReader)

if err != nil {
return nil, err
}

defer resp.Body.Close()

var rb struct {
AlreadyConsumed bool `json:"alreadyConsumed"`
}

if err := json.NewDecoder(resp.Body).Decode(&rb); err != nil {
return nil, err
}

if rb.AlreadyConsumed {
return nil, ErrTokenAlreadyConsumed
}

return decodedAppCheckToken, nil
}

func buildTokenVerifierUrl(projectId string) string {
return fmt.Sprintf("%s/v1beta/projects/%s:verifyAppCheckToken", tokenVerifierBaseUrl, projectId)
}

func contains(s []string, str string) bool {
for _, v := range s {
if v == str {
Expand Down
74 changes: 74 additions & 0 deletions appcheck/appcheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,80 @@ import (
"github.com/google/go-cmp/cmp"
)

func TestVerifyOneTimeToken(t *testing.T) {

projectID := "project_id"

ts, err := setupFakeJWKS()
if err != nil {
t.Fatalf("error setting up fake JWKS server: %v", err)
}
defer ts.Close()

privateKey, err := loadPrivateKey()
if err != nil {
t.Fatalf("error loading private key: %v", err)
}

JWKSUrl = ts.URL
mockTime := time.Now()

jwtToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.RegisteredClaims{
Issuer: appCheckIssuer,
Audience: jwt.ClaimStrings([]string{"projects/12345678", "projects/" + projectID}),
Subject: "1:12345678:android:abcdef",
ExpiresAt: jwt.NewNumericDate(mockTime.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(mockTime),
NotBefore: jwt.NewNumericDate(mockTime.Add(-1 * time.Hour)),
})

// kid matches the key ID in testdata/mock.jwks.json,
// which is the public key matching to the private key
// in testdata/appcheck_pk.pem.
jwtToken.Header["kid"] = "FGQdnRlzAmKyKr6-Hg_kMQrBkj_H6i6ADnBQz4OI6BU"

token, err := jwtToken.SignedString(privateKey)

if err != nil {
t.Fatalf("failed to sign token: %v", err)
}

appCheckVerifyTestsTable := []struct {
label string
mockServerResponse string
expectedError error
}{
{label: "testWhenAlreadyConsumedResponseIsTrue", mockServerResponse: `{"alreadyConsumed": true}`, expectedError: ErrTokenAlreadyConsumed},
{label: "testWhenAlreadyConsumedResponseIsFalse", mockServerResponse: `{"alreadyConsumed": false}`, expectedError: nil},
}

for _, tt := range appCheckVerifyTestsTable {

t.Run(tt.label, func(t *testing.T) {
appCheckVerifyMockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(tt.mockServerResponse))
}))

client, err := NewClient(context.Background(), &internal.AppCheckConfig{
ProjectID: projectID,
})

if err != nil {
t.Fatalf("error creating new client: %v", err)
}

client.tokenVerifierUrl = appCheckVerifyMockServer.URL

_, err = client.VerifyOneTimeToken(token)

if !errors.Is(err, tt.expectedError) {
t.Errorf("failed to verify token; Expected: %v, but got: %v", tt.expectedError, err)
}
})

}
}

func TestVerifyTokenHasValidClaims(t *testing.T) {
ts, err := setupFakeJWKS()
if err != nil {
Expand Down