Skip to content

Commit 06eb0e0

Browse files
authored
Revoke Tokens (#77)
Adding TokensValidAfterMillis property, RevokeRefreshTokens(), and VerifyIDTokenAndCheckRevoked().
1 parent c764f49 commit 06eb0e0

File tree

9 files changed

+285
-55
lines changed

9 files changed

+285
-55
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
# Unreleased
2+
3+
### Token revocation
4+
- [added] A New ['VerifyIDTokenAndCheckRevoked(ctx, token)'](https://godoc.org/firebase.google.com/go/auth#Client.VerifyIDToken)
5+
method has been added to check for revoked ID tokens.
6+
- [added] A new method ['RevokeRefreshTokens(uid)'](https://godoc.org/firebase.google.com/go/auth#Client.RevokeRefreshTokens)
7+
has been added to invalidate all refresh tokens issued to a user.
8+
- [added] A new property
9+
`TokensValidAfterMillis` has been added to the ['UserRecord'](https://godoc.org/firebase.google.com/go/auth#UserRecord).
10+
This property stores the time of the revocation truncated to 1 second accuracy.
11+
212
- Import context from golang.org/x/net/ for 1.6 compatibility
313

414
# v2.4.0

auth/auth.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,13 +177,26 @@ func (c *Client) CustomTokenWithClaims(uid string, devClaims map[string]interfac
177177
return encodeToken(c.snr, defaultHeader(), payload)
178178
}
179179

180+
// RevokeRefreshTokens revokes all refresh tokens issued to a user.
181+
//
182+
// RevokeRefreshTokens updates the user's TokensValidAfterMillis to the current UTC second.
183+
// It is important that the server on which this is called has its clock set correctly and synchronized.
184+
//
185+
// While this revokes all sessions for a specified user and disables any new ID tokens for existing sessions
186+
// from getting minted, existing ID tokens may remain active until their natural expiration (one hour).
187+
// To verify that ID tokens are revoked, use `verifyIdTokenAndCheckRevoked(ctx, idToken)`.
188+
func (c *Client) RevokeRefreshTokens(ctx context.Context, uid string) error {
189+
return c.updateUser(ctx, uid, (&UserToUpdate{}).revokeRefreshTokens())
190+
}
191+
180192
// VerifyIDToken verifies the signature and payload of the provided ID token.
181193
//
182194
// VerifyIDToken accepts a signed JWT token string, and verifies that it is current, issued for the
183195
// correct Firebase project, and signed by the Google Firebase services in the cloud. It returns
184196
// a Token containing the decoded claims in the input JWT. See
185197
// https://firebase.google.com/docs/auth/admin/verify-id-tokens#retrieve_id_tokens_on_clients for
186198
// more details on how to obtain an ID token in a client app.
199+
// This does not check whether or not the token has been revoked. See `VerifyIDTokenAndCheckRevoked` below.
187200
func (c *Client) VerifyIDToken(idToken string) (*Token, error) {
188201
if c.projectID == "" {
189202
return nil, errors.New("project id not available")
@@ -237,6 +250,27 @@ func (c *Client) VerifyIDToken(idToken string) (*Token, error) {
237250
return p, nil
238251
}
239252

253+
// VerifyIDTokenAndCheckRevoked verifies the provided ID token and checks it has not been revoked.
254+
//
255+
// VerifyIDTokenAndCheckRevoked verifies the signature and payload of the provided ID token and
256+
// checks that it wasn't revoked. Uses VerifyIDToken() internally to verify the ID token JWT.
257+
func (c *Client) VerifyIDTokenAndCheckRevoked(ctx context.Context, idToken string) (*Token, error) {
258+
p, err := c.VerifyIDToken(idToken)
259+
if err != nil {
260+
return nil, err
261+
}
262+
263+
user, err := c.GetUser(ctx, p.UID)
264+
if err != nil {
265+
return nil, err
266+
}
267+
268+
if p.IssuedAt*1000 < user.TokensValidAfterMillis {
269+
return nil, fmt.Errorf("ID token has been revoked")
270+
}
271+
return p, nil
272+
}
273+
240274
func parseKey(key string) (*rsa.PrivateKey, error) {
241275
block, _ := pem.Decode([]byte(key))
242276
if block == nil {

auth/auth_test.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@ import (
3737
)
3838

3939
var client *Client
40+
var ctx context.Context
4041
var testIDToken string
4142
var testGetUserResponse []byte
4243
var testListUsersResponse []byte
43-
4444
var defaultTestOpts = []option.ClientOption{
4545
option.WithCredentialsFile("../testdata/service_account.json"),
4646
}
@@ -49,7 +49,6 @@ func TestMain(m *testing.M) {
4949
var (
5050
err error
5151
ks keySource
52-
ctx context.Context
5352
creds *google.DefaultCredentials
5453
opts []option.ClientOption
5554
)
@@ -193,6 +192,52 @@ func TestCustomTokenInvalidCredential(t *testing.T) {
193192
}
194193
}
195194

195+
func TestVerifyIDTokenAndCheckRevokedValid(t *testing.T) {
196+
s := echoServer(testGetUserResponse, t)
197+
defer s.Close()
198+
199+
ft, err := s.Client.VerifyIDTokenAndCheckRevoked(ctx, testIDToken)
200+
if err != nil {
201+
t.Error(err)
202+
}
203+
if ft.Claims["admin"] != true {
204+
t.Errorf("Claims['admin'] = %v; want = true", ft.Claims["admin"])
205+
}
206+
if ft.UID != ft.Subject {
207+
t.Errorf("UID = %q; Sub = %q; want UID = Sub", ft.UID, ft.Subject)
208+
}
209+
}
210+
211+
func TestVerifyIDTokenAndCheckRevokedDoNotCheck(t *testing.T) {
212+
s := echoServer(testGetUserResponse, t)
213+
defer s.Close()
214+
tok := getIDToken(mockIDTokenPayload{"uid": "uid", "iat": 1970}) // old token
215+
216+
ft, err := s.Client.VerifyIDToken(tok)
217+
if err != nil {
218+
t.Fatal(err)
219+
}
220+
if ft.Claims["admin"] != true {
221+
t.Errorf("Claims['admin'] = %v; want = true", ft.Claims["admin"])
222+
}
223+
if ft.UID != ft.Subject {
224+
t.Errorf("UID = %q; Sub = %q; want UID = Sub", ft.UID, ft.Subject)
225+
}
226+
}
227+
228+
func TestVerifyIDTokenAndCheckRevokedInvalidated(t *testing.T) {
229+
s := echoServer(testGetUserResponse, t)
230+
defer s.Close()
231+
tok := getIDToken(mockIDTokenPayload{"uid": "uid", "iat": 1970}) // old token
232+
233+
p, err := s.Client.VerifyIDTokenAndCheckRevoked(ctx, tok)
234+
we := "ID token has been revoked"
235+
if p != nil || err == nil || err.Error() != we {
236+
t.Errorf("VerifyIDTokenAndCheckRevoked(ctx, token) =(%v, %v); want = (%v, %v)",
237+
p, err, nil, we)
238+
}
239+
}
240+
196241
func TestVerifyIDToken(t *testing.T) {
197242
ft, err := client.VerifyIDToken(testIDToken)
198243
if err != nil {

auth/user_mgt.go

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"reflect"
2222
"regexp"
2323
"strings"
24+
"time"
2425

2526
"golang.org/x/net/context"
2627
"google.golang.org/api/identitytoolkit/v3"
@@ -39,6 +40,7 @@ var commonValidators = map[string]func(interface{}) error{
3940
"password": validatePassword,
4041
"photoUrl": validatePhotoURL,
4142
"localId": validateUID,
43+
"validSince": func(interface{}) error { return nil }, // Needed for preparePayload.
4244
}
4345

4446
// Create a new interface
@@ -65,6 +67,7 @@ type UserInfo struct {
6567
}
6668

6769
// UserMetadata contains additional metadata associated with a user account.
70+
// Timestamps are in milliseconds since epoch.
6871
type UserMetadata struct {
6972
CreationTimestamp int64
7073
LastLogInTimestamp int64
@@ -73,11 +76,12 @@ type UserMetadata struct {
7376
// UserRecord contains metadata associated with a Firebase user account.
7477
type UserRecord struct {
7578
*UserInfo
76-
CustomClaims map[string]interface{}
77-
Disabled bool
78-
EmailVerified bool
79-
ProviderUserInfo []*UserInfo
80-
UserMetadata *UserMetadata
79+
CustomClaims map[string]interface{}
80+
Disabled bool
81+
EmailVerified bool
82+
ProviderUserInfo []*UserInfo
83+
TokensValidAfterMillis int64 // milliseconds since epoch.
84+
UserMetadata *UserMetadata
8185
}
8286

8387
// ExportedUserRecord is the returned user value used when listing all the users.
@@ -173,6 +177,13 @@ func (u *UserToUpdate) PhoneNumber(phone string) *UserToUpdate { u.set("phoneNum
173177
// PhotoURL setter.
174178
func (u *UserToUpdate) PhotoURL(url string) *UserToUpdate { u.set("photoUrl", url); return u }
175179

180+
// revokeRefreshTokens revokes all refresh tokens for a user by setting the validSince property
181+
// to the present in epoch seconds.
182+
func (u *UserToUpdate) revokeRefreshTokens() *UserToUpdate {
183+
u.set("validSince", time.Now().Unix())
184+
return u
185+
}
186+
176187
// CreateUser creates a new user with the specified properties.
177188
func (c *Client) CreateUser(ctx context.Context, user *UserToCreate) (*UserRecord, error) {
178189
uid, err := c.createUser(ctx, user)
@@ -471,7 +482,12 @@ func (u *UserToUpdate) preparePayload(user *identitytoolkit.IdentitytoolkitRelyi
471482
if err := validate(v); err != nil {
472483
return err
473484
}
474-
reflect.ValueOf(user).Elem().FieldByName(strings.Title(key)).SetString(params[key].(string))
485+
f := reflect.ValueOf(user).Elem().FieldByName(strings.Title(key))
486+
if f.Kind() == reflect.String {
487+
f.SetString(params[key].(string))
488+
} else if f.Kind() == reflect.Int64 {
489+
f.SetInt(params[key].(int64))
490+
}
475491
}
476492
}
477493
if params["disableUser"] != nil {
@@ -528,7 +544,6 @@ func (c *Client) updateUser(ctx context.Context, uid string, user *UserToUpdate)
528544
if user == nil || user.params == nil {
529545
return fmt.Errorf("update parameters must not be nil or empty")
530546
}
531-
532547
request := &identitytoolkit.IdentitytoolkitRelyingpartySetAccountInfoRequest{
533548
LocalId: uid,
534549
}
@@ -597,10 +612,11 @@ func makeExportedUser(r *identitytoolkit.UserInfo) (*ExportedUserRecord, error)
597612
ProviderID: defaultProviderID,
598613
UID: r.LocalId,
599614
},
600-
CustomClaims: cc,
601-
Disabled: r.Disabled,
602-
EmailVerified: r.EmailVerified,
603-
ProviderUserInfo: providerUserInfo,
615+
CustomClaims: cc,
616+
Disabled: r.Disabled,
617+
EmailVerified: r.EmailVerified,
618+
ProviderUserInfo: providerUserInfo,
619+
TokensValidAfterMillis: r.ValidSince * 1000,
604620
UserMetadata: &UserMetadata{
605621
LastLogInTimestamp: r.LastLoginAt,
606622
CreationTimestamp: r.CreatedAt,

auth/user_mgt_test.go

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"reflect"
2525
"strings"
2626
"testing"
27+
"time"
2728

2829
"firebase.google.com/go/internal"
2930

@@ -59,9 +60,10 @@ var testUser = &UserRecord{
5960
UID: "testuid",
6061
},
6162
},
63+
TokensValidAfterMillis: 1494364393000,
6264
UserMetadata: &UserMetadata{
63-
CreationTimestamp: 1234567890,
64-
LastLogInTimestamp: 1233211232,
65+
CreationTimestamp: 1234567890000,
66+
LastLogInTimestamp: 1233211232000,
6567
},
6668
CustomClaims: map[string]interface{}{"admin": true, "package": "gold"},
6769
}
@@ -496,6 +498,41 @@ func TestUpdateUser(t *testing.T) {
496498
}
497499
}
498500
}
501+
func TestRevokeRefreshTokens(t *testing.T) {
502+
resp := `{
503+
"kind": "identitytoolkit#SetAccountInfoResponse",
504+
"localId": "expectedUserID"
505+
}`
506+
s := echoServer([]byte(resp), t)
507+
defer s.Close()
508+
before := time.Now().Unix()
509+
if err := s.Client.RevokeRefreshTokens(context.Background(), "some_uid"); err != nil {
510+
t.Error(err)
511+
}
512+
after := time.Now().Unix()
513+
514+
req := &identitytoolkit.IdentitytoolkitRelyingpartySetAccountInfoRequest{}
515+
if err := json.Unmarshal(s.Rbody, &req); err != nil {
516+
t.Error(err)
517+
}
518+
if req.ValidSince > after || req.ValidSince < before {
519+
t.Errorf("validSince = %d, expecting time between %d and %d", req.ValidSince, before, after)
520+
}
521+
}
522+
523+
func TestRevokeRefreshTokensInvalidUID(t *testing.T) {
524+
resp := `{
525+
"kind": "identitytoolkit#SetAccountInfoResponse",
526+
"localId": "expectedUserID"
527+
}`
528+
s := echoServer([]byte(resp), t)
529+
defer s.Close()
530+
531+
we := "uid must not be empty"
532+
if err := s.Client.RevokeRefreshTokens(context.Background(), ""); err == nil || err.Error() != we {
533+
t.Errorf("RevokeRefreshTokens(); err = %s; want err = %s", err.Error(), we)
534+
}
535+
}
499536

500537
func TestInvalidSetCustomClaims(t *testing.T) {
501538
cases := []struct {
@@ -609,8 +646,8 @@ func TestMakeExportedUser(t *testing.T) {
609646
PasswordHash: "passwordhash",
610647
ValidSince: 1494364393,
611648
Disabled: false,
612-
CreatedAt: 1234567890,
613-
LastLoginAt: 1233211232,
649+
CreatedAt: 1234567890000,
650+
LastLoginAt: 1233211232000,
614651
CustomAttributes: `{"admin": true, "package": "gold"}`,
615652
ProviderUserInfo: []*identitytoolkit.UserInfoProviderUserInfo{
616653
{
@@ -637,7 +674,8 @@ func TestMakeExportedUser(t *testing.T) {
637674
}
638675
if !reflect.DeepEqual(exported.UserRecord, want.UserRecord) {
639676
// zero in
640-
t.Errorf("makeExportedUser() = %#v; want: %#v", exported.UserRecord, want.UserRecord)
677+
t.Errorf("makeExportedUser() = %#v; want: %#v \n(%#v)\n(%#v)", exported.UserRecord, want.UserRecord,
678+
exported.UserMetadata, want.UserMetadata)
641679
}
642680
if exported.PasswordHash != want.PasswordHash {
643681
t.Errorf("PasswordHash = %q; want = %q", exported.PasswordHash, want.PasswordHash)
@@ -690,8 +728,7 @@ func echoServer(resp interface{}, t *testing.T) *mockAuthServer {
690728
case []byte:
691729
b = v
692730
default:
693-
b, err = json.Marshal(resp)
694-
if err != nil {
731+
if b, err = json.Marshal(resp); err != nil {
695732
t.Fatal("marshaling error")
696733
}
697734
}
@@ -729,17 +766,17 @@ func echoServer(resp interface{}, t *testing.T) *mockAuthServer {
729766
}
730767
w.Header().Set("Content-Type", "application/json")
731768
w.Write(s.Resp)
732-
733769
})
734770
s.Srv = httptest.NewServer(handler)
735-
736771
conf := &internal.AuthConfig{
737772
Opts: []option.ClientOption{
738-
option.WithTokenSource(&mockTokenSource{testToken}),
739-
},
740-
Version: testVersion,
773+
option.WithTokenSource(&mockTokenSource{testToken})},
774+
ProjectID: "mock-project-id",
775+
Version: testVersion,
741776
}
742-
authClient, err := NewClient(context.Background(), conf)
777+
778+
authClient, err := NewClient(ctx, conf)
779+
authClient.ks = &fileKeySource{FilePath: "../testdata/public_certs.json"}
743780
if err != nil {
744781
t.Fatal(err)
745782
}

0 commit comments

Comments
 (0)