Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
cf9b75e
mplementation of revoke
avishalom Jan 25, 2018
8d967ef
mplementation of revoke
avishalom Jan 25, 2018
e828539
whitespace
avishalom Jan 25, 2018
1e0db76
undo echo refactor
avishalom Jan 25, 2018
d046362
undo reflect tests for create
avishalom Jan 25, 2018
de72ac5
test valid time
avishalom Jan 25, 2018
148fd88
integration tests
avishalom Jan 25, 2018
6b02152
lint
avishalom Jan 26, 2018
f199257
comment
avishalom Jan 26, 2018
c789e3e
comment
avishalom Jan 26, 2018
bfb6a01
typo
avishalom Jan 26, 2018
48910ad
clean redundant property
avishalom Jan 28, 2018
1192a1c
change test for invalid token
avishalom Jan 29, 2018
5b82970
validator
avishalom Jan 29, 2018
7e72fdc
add test
avishalom Jan 29, 2018
f859f6a
revoke takes UID
avishalom Jan 29, 2018
42e5d8d
test revoked but not checked
avishalom Jan 29, 2018
cad7bed
Merge branch 'dev' of github.com:firebase/firebase-admin-go into revoke
avishalom Jan 29, 2018
514d0a3
changelog
avishalom Jan 29, 2018
d4fb73d
tokensValidAfter in millies ; do not fail on existing users in tests.
avishalom Jan 30, 2018
7cd2eaf
addressing PR comments
avishalom Jan 31, 2018
4e09f44
remove boolean checkRevoked from VerifyIDTokenWithCheckRevoked
avishalom Feb 1, 2018
b18dab2
PR comment fixes
avishalom Feb 1, 2018
877707e
Merge branch 'dev' into revoke
avishalom Feb 1, 2018
1c0d9d6
PR comment fixes
avishalom Feb 2, 2018
19fbeb0
s/Time/Millis/; s/WithCheck/AndCheck/
avishalom Feb 2, 2018
a7ebce7
Change timestamp tests to millis
avishalom Feb 2, 2018
23a5c7d
millis fix
avishalom Feb 2, 2018
b1c6caa
fixes
avishalom Feb 5, 2018
bf6b1ab
remove existing user check
avishalom Feb 5, 2018
0bf8041
PR comments
avishalom Feb 6, 2018
0b91a4a
capitalization
avishalom Feb 6, 2018
f20054e
typo
avishalom Feb 6, 2018
4c89502
merge dev
avishalom Feb 13, 2018
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
# Unreleased

### Token revocation
- [added] A New ['VerifyIDTokenAndCheckRevoked(ctx, token)'](https://godoc.org/firebase.google.com/go/auth#Client.VerifyIDToken)
method has been added to check for revoked ID tokens.
- [added] A new method ['RevokeRefreshTokens(uid)'](https://godoc.org/firebase.google.com/go/auth#Client.RevokeRefreshTokens)
has been added to invalidate all refresh tokens issued to a user.
- [added] A new property
`TokensValidAfterMillis` has been added to the ['UserRecord'](https://godoc.org/firebase.google.com/go/auth#UserRecord).
This property stores the time of the revocation truncated to 1 second accuracy.

- Import context from golang.org/x/net/ for 1.6 compatibility

# v2.4.0
Expand Down
34 changes: 34 additions & 0 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,13 +177,26 @@ func (c *Client) CustomTokenWithClaims(uid string, devClaims map[string]interfac
return encodeToken(c.snr, defaultHeader(), payload)
}

// RevokeRefreshTokens revokes all refresh tokens issued to a user.
//
// RevokeRefreshTokens updates the user's TokensValidAfterMillis to the current UTC second.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/second/time.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I guess second is correct. I misread it before.

// It is important that the server on which this is called has its clock set correctly and synchronized.
//
// While this revokes all sessions for a specified user and disables any new ID tokens for existing sessions
// from getting minted, existing ID tokens may remain active until their natural expiration (one hour).
// To verify that ID tokens are revoked, use `verifyIdTokenAndCheckRevoked(ctx, idToken)`.
func (c *Client) RevokeRefreshTokens(ctx context.Context, uid string) error {
return c.updateUser(ctx, uid, (&UserToUpdate{}).revokeRefreshTokens())
}

// VerifyIDToken verifies the signature and payload of the provided ID token.
//
// VerifyIDToken accepts a signed JWT token string, and verifies that it is current, issued for the
// correct Firebase project, and signed by the Google Firebase services in the cloud. It returns
// a Token containing the decoded claims in the input JWT. See
// https://firebase.google.com/docs/auth/admin/verify-id-tokens#retrieve_id_tokens_on_clients for
// more details on how to obtain an ID token in a client app.
// This does not check whether or not the token has been revoked. See `VerifyIDTokenAndCheckRevoked` below.
func (c *Client) VerifyIDToken(idToken string) (*Token, error) {
if c.projectID == "" {
return nil, errors.New("project id not available")
Expand Down Expand Up @@ -237,6 +250,27 @@ func (c *Client) VerifyIDToken(idToken string) (*Token, error) {
return p, nil
}

// VerifyIDTokenAndCheckRevoked verifies the provided ID token and checks it has not been revoked.
//
// VerifyIDTokenAndCheckRevoked verifies the signature and payload of the provided ID token and
// checks that it wasn't revoked. Uses VerifyIDToken() internally to verify the ID token JWT.
func (c *Client) VerifyIDTokenAndCheckRevoked(ctx context.Context, idToken string) (*Token, error) {
p, err := c.VerifyIDToken(idToken)
if err != nil {
return nil, err
}

user, err := c.GetUser(ctx, p.UID)
if err != nil {
return nil, err
}

if p.IssuedAt*1000 < user.TokensValidAfterMillis {
return nil, fmt.Errorf("ID token has been revoked")
}
return p, nil
}

func parseKey(key string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(key))
if block == nil {
Expand Down
49 changes: 47 additions & 2 deletions auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ import (
)

var client *Client
var ctx context.Context
var testIDToken string
var testGetUserResponse []byte
var testListUsersResponse []byte

var defaultTestOpts = []option.ClientOption{
option.WithCredentialsFile("../testdata/service_account.json"),
}
Expand All @@ -49,7 +49,6 @@ func TestMain(m *testing.M) {
var (
err error
ks keySource
ctx context.Context
creds *google.DefaultCredentials
opts []option.ClientOption
)
Expand Down Expand Up @@ -193,6 +192,52 @@ func TestCustomTokenInvalidCredential(t *testing.T) {
}
}

func TestVerifyIDTokenAndCheckRevokedValid(t *testing.T) {
s := echoServer(testGetUserResponse, t)
defer s.Close()

ft, err := s.Client.VerifyIDTokenAndCheckRevoked(ctx, testIDToken)
if err != nil {
t.Error(err)
}
if ft.Claims["admin"] != true {
t.Errorf("Claims['admin'] = %v; want = true", ft.Claims["admin"])
}
if ft.UID != ft.Subject {
t.Errorf("UID = %q; Sub = %q; want UID = Sub", ft.UID, ft.Subject)
}
}

func TestVerifyIDTokenAndCheckRevokedDoNotCheck(t *testing.T) {
s := echoServer(testGetUserResponse, t)
defer s.Close()
tok := getIDToken(mockIDTokenPayload{"uid": "uid", "iat": 1970}) // old token

ft, err := s.Client.VerifyIDToken(tok)
if err != nil {
t.Fatal(err)
}
if ft.Claims["admin"] != true {
t.Errorf("Claims['admin'] = %v; want = true", ft.Claims["admin"])
}
if ft.UID != ft.Subject {
t.Errorf("UID = %q; Sub = %q; want UID = Sub", ft.UID, ft.Subject)
}
}

func TestVerifyIDTokenAndCheckRevokedInvalidated(t *testing.T) {
s := echoServer(testGetUserResponse, t)
defer s.Close()
tok := getIDToken(mockIDTokenPayload{"uid": "uid", "iat": 1970}) // old token

p, err := s.Client.VerifyIDTokenAndCheckRevoked(ctx, tok)
we := "ID token has been revoked"
if p != nil || err == nil || err.Error() != we {
t.Errorf("VerifyIDTokenAndCheckRevoked(ctx, token) =(%v, %v); want = (%v, %v)",
p, err, nil, we)
}
}

func TestVerifyIDToken(t *testing.T) {
ft, err := client.VerifyIDToken(testIDToken)
if err != nil {
Expand Down
38 changes: 27 additions & 11 deletions auth/user_mgt.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"reflect"
"regexp"
"strings"
"time"

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

// Create a new interface
Expand All @@ -65,6 +67,7 @@ type UserInfo struct {
}

// UserMetadata contains additional metadata associated with a user account.
// Timestamps are in milliseconds since epoch.
type UserMetadata struct {
CreationTimestamp int64
LastLogInTimestamp int64
Expand All @@ -73,11 +76,12 @@ type UserMetadata struct {
// UserRecord contains metadata associated with a Firebase user account.
type UserRecord struct {
*UserInfo
CustomClaims map[string]interface{}
Disabled bool
EmailVerified bool
ProviderUserInfo []*UserInfo
UserMetadata *UserMetadata
CustomClaims map[string]interface{}
Disabled bool
EmailVerified bool
ProviderUserInfo []*UserInfo
TokensValidAfterMillis int64 // milliseconds since epoch.
UserMetadata *UserMetadata
}

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

// revokeRefreshTokens revokes all refresh tokens for a user by setting the validSince property
// to the present in epoch seconds.
func (u *UserToUpdate) revokeRefreshTokens() *UserToUpdate {
u.set("validSince", time.Now().Unix())
return u
}

// CreateUser creates a new user with the specified properties.
func (c *Client) CreateUser(ctx context.Context, user *UserToCreate) (*UserRecord, error) {
uid, err := c.createUser(ctx, user)
Expand Down Expand Up @@ -471,7 +482,12 @@ func (u *UserToUpdate) preparePayload(user *identitytoolkit.IdentitytoolkitRelyi
if err := validate(v); err != nil {
return err
}
reflect.ValueOf(user).Elem().FieldByName(strings.Title(key)).SetString(params[key].(string))
f := reflect.ValueOf(user).Elem().FieldByName(strings.Title(key))
if f.Kind() == reflect.String {
f.SetString(params[key].(string))
} else if f.Kind() == reflect.Int64 {
f.SetInt(params[key].(int64))
}
}
}
if params["disableUser"] != nil {
Expand Down Expand Up @@ -528,7 +544,6 @@ func (c *Client) updateUser(ctx context.Context, uid string, user *UserToUpdate)
if user == nil || user.params == nil {
return fmt.Errorf("update parameters must not be nil or empty")
}

request := &identitytoolkit.IdentitytoolkitRelyingpartySetAccountInfoRequest{
LocalId: uid,
}
Expand Down Expand Up @@ -597,10 +612,11 @@ func makeExportedUser(r *identitytoolkit.UserInfo) (*ExportedUserRecord, error)
ProviderID: defaultProviderID,
UID: r.LocalId,
},
CustomClaims: cc,
Disabled: r.Disabled,
EmailVerified: r.EmailVerified,
ProviderUserInfo: providerUserInfo,
CustomClaims: cc,
Disabled: r.Disabled,
EmailVerified: r.EmailVerified,
ProviderUserInfo: providerUserInfo,
TokensValidAfterMillis: r.ValidSince * 1000,
UserMetadata: &UserMetadata{
LastLogInTimestamp: r.LastLoginAt,
CreationTimestamp: r.CreatedAt,
Expand Down
63 changes: 50 additions & 13 deletions auth/user_mgt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"reflect"
"strings"
"testing"
"time"

"firebase.google.com/go/internal"

Expand Down Expand Up @@ -59,9 +60,10 @@ var testUser = &UserRecord{
UID: "testuid",
},
},
TokensValidAfterMillis: 1494364393000,
UserMetadata: &UserMetadata{
CreationTimestamp: 1234567890,
LastLogInTimestamp: 1233211232,
CreationTimestamp: 1234567890000,
LastLogInTimestamp: 1233211232000,
},
CustomClaims: map[string]interface{}{"admin": true, "package": "gold"},
}
Expand Down Expand Up @@ -496,6 +498,41 @@ func TestUpdateUser(t *testing.T) {
}
}
}
func TestRevokeRefreshTokens(t *testing.T) {
resp := `{
"kind": "identitytoolkit#SetAccountInfoResponse",
"localId": "expectedUserID"
}`
s := echoServer([]byte(resp), t)
defer s.Close()
before := time.Now().Unix()
if err := s.Client.RevokeRefreshTokens(context.Background(), "some_uid"); err != nil {
t.Error(err)
}
after := time.Now().Unix()

req := &identitytoolkit.IdentitytoolkitRelyingpartySetAccountInfoRequest{}
if err := json.Unmarshal(s.Rbody, &req); err != nil {
t.Error(err)
}
if req.ValidSince > after || req.ValidSince < before {
t.Errorf("validSince = %d, expecting time between %d and %d", req.ValidSince, before, after)
}
}

func TestRevokeRefreshTokensInvalidUID(t *testing.T) {
resp := `{
"kind": "identitytoolkit#SetAccountInfoResponse",
"localId": "expectedUserID"
}`
s := echoServer([]byte(resp), t)
defer s.Close()

we := "uid must not be empty"
if err := s.Client.RevokeRefreshTokens(context.Background(), ""); err == nil || err.Error() != we {
t.Errorf("RevokeRefreshTokens(); err = %s; want err = %s", err.Error(), we)
}
}

func TestInvalidSetCustomClaims(t *testing.T) {
cases := []struct {
Expand Down Expand Up @@ -609,8 +646,8 @@ func TestMakeExportedUser(t *testing.T) {
PasswordHash: "passwordhash",
ValidSince: 1494364393,
Disabled: false,
CreatedAt: 1234567890,
LastLoginAt: 1233211232,
CreatedAt: 1234567890000,
LastLoginAt: 1233211232000,
CustomAttributes: `{"admin": true, "package": "gold"}`,
ProviderUserInfo: []*identitytoolkit.UserInfoProviderUserInfo{
{
Expand All @@ -637,7 +674,8 @@ func TestMakeExportedUser(t *testing.T) {
}
if !reflect.DeepEqual(exported.UserRecord, want.UserRecord) {
// zero in
t.Errorf("makeExportedUser() = %#v; want: %#v", exported.UserRecord, want.UserRecord)
t.Errorf("makeExportedUser() = %#v; want: %#v \n(%#v)\n(%#v)", exported.UserRecord, want.UserRecord,
exported.UserMetadata, want.UserMetadata)
}
if exported.PasswordHash != want.PasswordHash {
t.Errorf("PasswordHash = %q; want = %q", exported.PasswordHash, want.PasswordHash)
Expand Down Expand Up @@ -690,8 +728,7 @@ func echoServer(resp interface{}, t *testing.T) *mockAuthServer {
case []byte:
b = v
default:
b, err = json.Marshal(resp)
if err != nil {
if b, err = json.Marshal(resp); err != nil {
t.Fatal("marshaling error")
}
}
Expand Down Expand Up @@ -729,17 +766,17 @@ func echoServer(resp interface{}, t *testing.T) *mockAuthServer {
}
w.Header().Set("Content-Type", "application/json")
w.Write(s.Resp)

})
s.Srv = httptest.NewServer(handler)

conf := &internal.AuthConfig{
Opts: []option.ClientOption{
option.WithTokenSource(&mockTokenSource{testToken}),
},
Version: testVersion,
option.WithTokenSource(&mockTokenSource{testToken})},
ProjectID: "mock-project-id",
Version: testVersion,
}
authClient, err := NewClient(context.Background(), conf)

authClient, err := NewClient(ctx, conf)
authClient.ks = &fileKeySource{FilePath: "../testdata/public_certs.json"}
if err != nil {
t.Fatal(err)
}
Expand Down
Loading