Skip to content

Commit 606008d

Browse files
authored
feat: Add App Check token verification (#484)
Add API to verify app check tokens
1 parent f842381 commit 606008d

File tree

8 files changed

+525
-1
lines changed

8 files changed

+525
-1
lines changed

appcheck/appcheck.go

+176
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright 2022 Google Inc. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package appcheck provides functionality for verifying App Check tokens.
16+
package appcheck
17+
18+
import (
19+
"context"
20+
"errors"
21+
"strings"
22+
"time"
23+
24+
"github.com/MicahParks/keyfunc"
25+
"github.com/golang-jwt/jwt/v4"
26+
27+
"firebase.google.com/go/v4/internal"
28+
)
29+
30+
// JWKSUrl is the URL of the JWKS used to verify App Check tokens.
31+
var JWKSUrl = "https://firebaseappcheck.googleapis.com/v1beta/jwks"
32+
33+
const appCheckIssuer = "https://firebaseappcheck.googleapis.com/"
34+
35+
var (
36+
// ErrIncorrectAlgorithm is returned when the token is signed with a non-RSA256 algorithm.
37+
ErrIncorrectAlgorithm = errors.New("token has incorrect algorithm")
38+
// ErrTokenType is returned when the token is not a JWT.
39+
ErrTokenType = errors.New("token has incorrect type")
40+
// ErrTokenClaims is returned when the token claims cannot be decoded.
41+
ErrTokenClaims = errors.New("token has incorrect claims")
42+
// ErrTokenAudience is returned when the token audience does not match the current project.
43+
ErrTokenAudience = errors.New("token has incorrect audience")
44+
// ErrTokenIssuer is returned when the token issuer does not match Firebase's App Check service.
45+
ErrTokenIssuer = errors.New("token has incorrect issuer")
46+
// ErrTokenSubject is returned when the token subject is empty or missing.
47+
ErrTokenSubject = errors.New("token has empty or missing subject")
48+
)
49+
50+
// DecodedAppCheckToken represents a verified App Check token.
51+
//
52+
// DecodedAppCheckToken provides typed accessors to the common JWT fields such as Audience (aud)
53+
// and ExpiresAt (exp). Additionally it provides an AppID field, which indicates the application ID to which this
54+
// token belongs. Any additional JWT claims can be accessed via the Claims map of DecodedAppCheckToken.
55+
type DecodedAppCheckToken struct {
56+
Issuer string
57+
Subject string
58+
Audience []string
59+
ExpiresAt time.Time
60+
IssuedAt time.Time
61+
AppID string
62+
Claims map[string]interface{}
63+
}
64+
65+
// Client is the interface for the Firebase App Check service.
66+
type Client struct {
67+
projectID string
68+
jwks *keyfunc.JWKS
69+
}
70+
71+
// NewClient creates a new instance of the Firebase App Check Client.
72+
//
73+
// This function can only be invoked from within the SDK. Client applications should access the
74+
// the App Check service through firebase.App.
75+
func NewClient(ctx context.Context, conf *internal.AppCheckConfig) (*Client, error) {
76+
// TODO: Add support for overriding the HTTP client using the App one.
77+
jwks, err := keyfunc.Get(JWKSUrl, keyfunc.Options{
78+
Ctx: ctx,
79+
RefreshInterval: 6 * time.Hour,
80+
})
81+
if err != nil {
82+
return nil, err
83+
}
84+
85+
return &Client{
86+
projectID: conf.ProjectID,
87+
jwks: jwks,
88+
}, nil
89+
}
90+
91+
// VerifyToken verifies the given App Check token.
92+
//
93+
// VerifyToken considers an App Check token string to be valid if all the following conditions are met:
94+
// - The token string is a valid RS256 JWT.
95+
// - The JWT contains valid issuer (iss) and audience (aud) claims that match the issuerPrefix
96+
// and projectID of the tokenVerifier.
97+
// - The JWT contains a valid subject (sub) claim.
98+
// - The JWT is not expired, and it has been issued some time in the past.
99+
// - The JWT is signed by a Firebase App Check backend server as determined by the keySource.
100+
//
101+
// If any of the above conditions are not met, an error is returned. Otherwise a pointer to a
102+
// decoded App Check token is returned.
103+
func (c *Client) VerifyToken(token string) (*DecodedAppCheckToken, error) {
104+
// References for checks:
105+
// https://firebase.googleblog.com/2021/10/protecting-backends-with-app-check.html
106+
// https://github.com/firebase/firebase-admin-node/blob/master/src/app-check/token-verifier.ts#L106
107+
108+
// The standard JWT parser also validates the expiration of the token
109+
// so we do not need dedicated code for that.
110+
decodedToken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
111+
if t.Header["alg"] != "RS256" {
112+
return nil, ErrIncorrectAlgorithm
113+
}
114+
if t.Header["typ"] != "JWT" {
115+
return nil, ErrTokenType
116+
}
117+
return c.jwks.Keyfunc(t)
118+
})
119+
if err != nil {
120+
return nil, err
121+
}
122+
123+
claims, ok := decodedToken.Claims.(jwt.MapClaims)
124+
if !ok {
125+
return nil, ErrTokenClaims
126+
}
127+
128+
rawAud := claims["aud"].([]interface{})
129+
aud := []string{}
130+
for _, v := range rawAud {
131+
aud = append(aud, v.(string))
132+
}
133+
134+
if !contains(aud, "projects/"+c.projectID) {
135+
return nil, ErrTokenAudience
136+
}
137+
138+
// We check the prefix to make sure this token was issued
139+
// by the Firebase App Check service, but we do not check the
140+
// Project Number suffix because the Golang SDK only has project ID.
141+
//
142+
// This is consistent with the Firebase Admin Node SDK.
143+
if !strings.HasPrefix(claims["iss"].(string), appCheckIssuer) {
144+
return nil, ErrTokenIssuer
145+
}
146+
147+
if val, ok := claims["sub"].(string); !ok || val == "" {
148+
return nil, ErrTokenSubject
149+
}
150+
151+
appCheckToken := DecodedAppCheckToken{
152+
Issuer: claims["iss"].(string),
153+
Subject: claims["sub"].(string),
154+
Audience: aud,
155+
ExpiresAt: time.Unix(int64(claims["exp"].(float64)), 0),
156+
IssuedAt: time.Unix(int64(claims["iat"].(float64)), 0),
157+
AppID: claims["sub"].(string),
158+
}
159+
160+
// Remove all the claims we've already parsed.
161+
for _, usedClaim := range []string{"iss", "sub", "aud", "exp", "iat", "sub"} {
162+
delete(claims, usedClaim)
163+
}
164+
appCheckToken.Claims = claims
165+
166+
return &appCheckToken, nil
167+
}
168+
169+
func contains(s []string, str string) bool {
170+
for _, v := range s {
171+
if v == str {
172+
return true
173+
}
174+
}
175+
return false
176+
}

0 commit comments

Comments
 (0)