Repository navigation
feat(appcheck): Verify one-time tokens for replay protection #774
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
Changes from all commits
ed9b1fb
b8487ba
6c999a4
604af8d
c746ccb
97e1f4d
3946367
863183a
7064317
688d513
b09857b
d580ec0
8acd3c3
0efa019
99b64d1
f1bbce2
6d3c8b2
9de05fb
a357931
ee86e00
455635d
4c681fe
96f384b
07f03dc
6c20837
c372167
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ package appcheck | |
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "strings" | ||
| "time" | ||
|
|
||
|
|
@@ -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. | ||
|
|
@@ -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. | ||
|
|
@@ -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") | ||
| } | ||
| jwks, err := keyfunc.Get(JWKSUrl, keyfunc.Options{ | ||
| Ctx: ctx, | ||
| RefreshInterval: 6 * time.Hour, | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit because I am unsure if we track this. hc.Opts = []internal.HTTPOption{
internal.WithHeader("x-goog-api-client", internal.GetMetricsHeader(config.Version)),
} |
||
| return &Client{ | ||
| projectID: conf.ProjectID, | ||
| jwks: jwks, | ||
| client: hc, | ||
| }, nil | ||
| } | ||
|
|
||
|
|
@@ -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 { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To prevent a potential nil pointer dereference, verify that
confis notnilbefore accessing its fields.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NewClientis only called internally fromApp.AppCheck, which always passes a non-nil config, andinternal.AppCheckConfigcan'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.