Skip to content

Commit

Permalink
fix(auth): check disabled status on verifyIDToken (#455)
Browse files Browse the repository at this point in the history
* check disabled status on verifyIDToken

* go fmt

* add test cases and some other tweks

* tweak some declarations, remove a double call to checkRevokedOrDisabled

* Added integration test for disabled status check

* make tweaks

* added unit test for VerifySessionCookie

* Undid public API rename

Co-authored-by: Google Open Source Bot <[email protected]>
Co-authored-by: Patrick Jones <[email protected]>
Co-authored-by: Hiranya Jayathilaka <[email protected]>
  • Loading branch information
4 people committed Aug 20, 2021
1 parent 4d3cc69 commit ea909b1
Show file tree
Hide file tree
Showing 8 changed files with 145 additions and 46 deletions.
75 changes: 41 additions & 34 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const (

// SDK-generated error codes
idTokenRevoked = "ID_TOKEN_REVOKED"
userDisabled = "USER_DISABLED"
sessionCookieRevoked = "SESSION_COOKIE_REVOKED"
tenantIDMismatch = "TENANT_ID_MISMATCH"
)
Expand Down Expand Up @@ -288,14 +289,14 @@ func (c *baseClient) withTenantID(tenantID string) *baseClient {
// These keys get cached up to 24 hours, and therefore the RPC overhead gets amortized
// over many invocations of this function.
//
// This does not check whether or not the token has been revoked. Use `VerifyIDTokenAndCheckRevoked()`
// This does not check whether or not the token has been revoked or disabled. Use `VerifyIDTokenAndCheckRevoked()`
// when a revocation check is needed.
func (c *baseClient) VerifyIDToken(ctx context.Context, idToken string) (*Token, error) {
return c.verifyIDToken(ctx, idToken, false)
}

// VerifyIDTokenAndCheckRevoked verifies the provided ID token, and additionally checks that the
// token has not been revoked.
// token has not been revoked or disabled.
//
// Unlike `VerifyIDToken()`, this function must make an RPC call to perform the revocation check.
// Developers are advised to take this additional overhead into consideration when including this
Expand All @@ -304,7 +305,7 @@ func (c *baseClient) VerifyIDTokenAndCheckRevoked(ctx context.Context, idToken s
return c.verifyIDToken(ctx, idToken, true)
}

func (c *baseClient) verifyIDToken(ctx context.Context, idToken string, checkRevoked bool) (*Token, error) {
func (c *baseClient) verifyIDToken(ctx context.Context, idToken string, checkRevokedOrDisabled bool) (*Token, error) {
decoded, err := c.idTokenVerifier.VerifyToken(ctx, idToken, c.isEmulator)
if err != nil {
return nil, err
Expand All @@ -320,21 +321,11 @@ func (c *baseClient) verifyIDToken(ctx context.Context, idToken string, checkRev
}
}

if c.isEmulator || checkRevoked {
revoked, err := c.checkRevoked(ctx, decoded)
if c.isEmulator || checkRevokedOrDisabled {
err = c.checkRevokedOrDisabled(ctx, decoded, idTokenRevoked, "ID token has been revoked")
if err != nil {
return nil, err
}

if revoked {
return nil, &internal.FirebaseError{
ErrorCode: internal.InvalidArgument,
String: "ID token has been revoked",
Ext: map[string]interface{}{
authErrorCode: idTokenRevoked,
},
}
}
}

return decoded, nil
Expand All @@ -347,11 +338,18 @@ func IsTenantIDMismatch(err error) bool {

// IsIDTokenRevoked checks if the given error was due to a revoked ID token.
//
// When IsIDTokenRevoked returns true, IsIDTokenInvalid is guranteed to return true.
// When IsIDTokenRevoked returns true, IsIDTokenInvalid is guaranteed to return true.
func IsIDTokenRevoked(err error) bool {
return hasAuthErrorCode(err, idTokenRevoked)
}

// IsUserDisabled checks if the given error was due to a disabled ID token
//
// When IsUserDisabled returns true, IsIDTokenInvalid is guaranteed to return true.
func IsUserDisabled(err error) bool {
return hasAuthErrorCode(err, userDisabled)
}

// VerifySessionCookie verifies the signature and payload of the provided Firebase session cookie.
//
// VerifySessionCookie accepts a signed JWT token string, and verifies that it is current, issued for the
Expand All @@ -371,7 +369,7 @@ func (c *Client) VerifySessionCookie(ctx context.Context, sessionCookie string)
}

// VerifySessionCookieAndCheckRevoked verifies the provided session cookie, and additionally checks that the
// cookie has not been revoked.
// cookie has not been revoked and the user has not been disabled.
//
// Unlike `VerifySessionCookie()`, this function must make an RPC call to perform the revocation check.
// Developers are advised to take this additional overhead into consideration when including this
Expand All @@ -380,46 +378,55 @@ func (c *Client) VerifySessionCookieAndCheckRevoked(ctx context.Context, session
return c.verifySessionCookie(ctx, sessionCookie, true)
}

func (c *Client) verifySessionCookie(ctx context.Context, sessionCookie string, checkRevoked bool) (*Token, error) {
func (c *Client) verifySessionCookie(ctx context.Context, sessionCookie string, checkRevokedOrDisabled bool) (*Token, error) {
decoded, err := c.cookieVerifier.VerifyToken(ctx, sessionCookie, c.isEmulator)
if err != nil {
return nil, err
}

if c.isEmulator || checkRevoked {
revoked, err := c.checkRevoked(ctx, decoded)
if c.isEmulator || checkRevokedOrDisabled {
err := c.checkRevokedOrDisabled(ctx, decoded, sessionCookieRevoked, "session cookie has been revoked")
if err != nil {
return nil, err
}

if revoked {
return nil, &internal.FirebaseError{
ErrorCode: internal.InvalidArgument,
String: "session cookie has been revoked",
Ext: map[string]interface{}{
authErrorCode: sessionCookieRevoked,
},
}
}
}

return decoded, nil
}

// IsSessionCookieRevoked checks if the given error was due to a revoked session cookie.
//
// When IsSessionCookieRevoked returns true, IsSessionCookieInvalid is guranteed to return true.
// When IsSessionCookieRevoked returns true, IsSessionCookieInvalid is guaranteed to return true.
func IsSessionCookieRevoked(err error) bool {
return hasAuthErrorCode(err, sessionCookieRevoked)
}

func (c *baseClient) checkRevoked(ctx context.Context, token *Token) (bool, error) {
// checkRevokedOrDisabled checks whether the input token has been revoked or disabled.
func (c *baseClient) checkRevokedOrDisabled(ctx context.Context, token *Token, errCode string, errMessage string) error {
user, err := c.GetUser(ctx, token.UID)
if err != nil {
return false, err
return err
}
if user.Disabled {
return &internal.FirebaseError{
ErrorCode: internal.InvalidArgument,
String: "user has been disabled",
Ext: map[string]interface{}{
authErrorCode: userDisabled,
},
}

return token.IssuedAt*1000 < user.TokensValidAfterMillis, nil
}
if token.IssuedAt*1000 < user.TokensValidAfterMillis {
return &internal.FirebaseError{
ErrorCode: internal.InvalidArgument,
String: errMessage,
Ext: map[string]interface{}{
authErrorCode: errCode,
},
}
}
return nil
}

func hasAuthErrorCode(err error, code string) bool {
Expand Down
48 changes: 40 additions & 8 deletions auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,13 @@ const (
)

var (
testGetUserResponse []byte
testIDToken string
testSessionCookie string
testSigner cryptoSigner
testIDTokenVerifier *tokenVerifier
testCookieVerifier *tokenVerifier
testGetUserResponse []byte
testGetDisabledUserResponse []byte
testIDToken string
testSessionCookie string
testSigner cryptoSigner
testIDTokenVerifier *tokenVerifier
testCookieVerifier *tokenVerifier

optsWithServiceAcct = []option.ClientOption{
option.WithCredentialsFile("../testdata/service_account.json"),
Expand All @@ -76,6 +77,9 @@ func TestMain(m *testing.M) {
testGetUserResponse, err = ioutil.ReadFile("../testdata/get_user.json")
logFatal(err)

testGetDisabledUserResponse, err = ioutil.ReadFile("../testdata/get_disabled_user.json")
logFatal(err)

testIDToken = getIDToken(nil)
testSessionCookie = getSessionCookie(nil)
os.Exit(m.Run())
Expand Down Expand Up @@ -852,13 +856,13 @@ func TestVerifyIDTokenDoesNotCheckRevoked(t *testing.T) {
}
}

func TestInvalidTokenDoesNotCheckRevoked(t *testing.T) {
func TestInvalidTokenDoesNotCheckRevokedOrDisabled(t *testing.T) {
s := echoServer(testGetUserResponse, t)
defer s.Close()
s.Client.idTokenVerifier = testIDTokenVerifier

ft, err := s.Client.VerifyIDTokenAndCheckRevoked(context.Background(), "")
if ft != nil || !IsIDTokenInvalid(err) || IsIDTokenRevoked(err) {
if ft != nil || !IsIDTokenInvalid(err) || IsIDTokenRevoked(err) || IsUserDisabled(err) {
t.Errorf("VerifyIDTokenAndCheckRevoked() = (%v, %v); want = (nil, IDTokenInvalid)", ft, err)
}
if len(s.Req) != 0 {
Expand All @@ -880,6 +884,20 @@ func TestVerifyIDTokenAndCheckRevokedError(t *testing.T) {
}
}

func TestVerifyIDTokenAndCheckDisabledError(t *testing.T) {
s := echoServer(testGetDisabledUserResponse, t)
defer s.Close()
revokedToken := getIDToken(mockIDTokenPayload{"uid": "uid", "iat": 1970})
s.Client.idTokenVerifier = testIDTokenVerifier

p, err := s.Client.VerifyIDTokenAndCheckRevoked(context.Background(), revokedToken)
we := "user has been disabled"
if p != nil || !IsUserDisabled(err) || !IsIDTokenInvalid(err) || err.Error() != we {
t.Errorf("VerifyIDTokenAndCheckRevoked(ctx, token) =(%v, %v); want = (%v, %v)",
p, err, nil, we)
}
}

func TestIDTokenRevocationCheckUserMgtError(t *testing.T) {
resp := `{
"kind" : "identitytoolkit#GetAccountInfoResponse",
Expand Down Expand Up @@ -1116,6 +1134,20 @@ func TestVerifySessionCookieAndCheckRevokedError(t *testing.T) {
}
}

func TestVerifySessionCookieAndCheckDisabledError(t *testing.T) {
s := echoServer(testGetDisabledUserResponse, t)
defer s.Close()
revokedCookie := getSessionCookie(mockIDTokenPayload{"uid": "uid", "iat": 1970})
s.Client.cookieVerifier = testCookieVerifier

p, err := s.Client.VerifySessionCookieAndCheckRevoked(context.Background(), revokedCookie)
we := "user has been disabled"
if p != nil || !IsUserDisabled(err) || !IsSessionCookieInvalid(err) || err.Error() != we {
t.Errorf("VerifySessionCookieAndCheckRevoked(ctx, token) =(%v, %v); want = (%v, %v)",
p, err, nil, we)
}
}

func TestCookieRevocationCheckUserMgtError(t *testing.T) {
resp := `{
"kind" : "identitytoolkit#GetAccountInfoResponse",
Expand Down
4 changes: 2 additions & 2 deletions auth/token_verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func IsIDTokenExpired(err error) bool {
// An ID token is considered invalid when it is malformed (i.e. contains incorrect data), expired
// or revoked.
func IsIDTokenInvalid(err error) bool {
return hasAuthErrorCode(err, idTokenInvalid) || IsIDTokenExpired(err) || IsIDTokenRevoked(err)
return hasAuthErrorCode(err, idTokenInvalid) || IsIDTokenExpired(err) || IsIDTokenRevoked(err) || IsUserDisabled(err)
}

// IsSessionCookieExpired checks if the given error was due to an expired session cookie.
Expand All @@ -85,7 +85,7 @@ func IsSessionCookieExpired(err error) bool {
// expired or revoked.
func IsSessionCookieInvalid(err error) bool {
return hasAuthErrorCode(err, sessionCookieInvalid) || IsSessionCookieExpired(err) ||
IsSessionCookieRevoked(err)
IsSessionCookieRevoked(err) || IsUserDisabled(err)
}

// tokenVerifier verifies different types of Firebase token strings, including ID tokens and
Expand Down
36 changes: 36 additions & 0 deletions integration/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,42 @@ func TestRevokeRefreshTokens(t *testing.T) {
}
}

func TestIDTokenForDisabledUser(t *testing.T) {
uid := "user_disabled"
ct, err := client.CustomToken(context.Background(), uid)
if err != nil {
t.Fatal(err)
}
idt, err := signInWithCustomToken(ct)
if err != nil {
t.Fatal(err)
}
defer deleteUser(uid)

vt, err := client.VerifyIDTokenAndCheckRevoked(context.Background(), idt)
if err != nil {
t.Fatal(err)
}
if vt.UID != uid {
t.Errorf("UID = %q; want UID = %q", vt.UID, uid)
}

// Disable the user
updates := auth.UserToUpdate{}
updates.Disabled(true)
_, err = client.UpdateUser(context.Background(), uid, &updates)
if err != nil {
t.Fatalf("failed to disable user with UpdateUser: %v", err)
}

vt, err = client.VerifyIDTokenAndCheckRevoked(context.Background(), idt)
we := "user has been disabled"
if vt != nil || err == nil || !auth.IsUserDisabled(err) || err.Error() != we {
t.Errorf("tok, err := VerifyIDTokenAndCheckRevoked(); got (%v, %s) ; want (%v, %v)",
vt, err, nil, we)
}
}

// verifyCustomToken verifies the given custom token by signing into a Firebase project with it.
//
// A successful sign in creates the user account in the Firebase back-end. This method ensures that
Expand Down
2 changes: 1 addition & 1 deletion integration/storage/storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import (
"testing"

gcs "cloud.google.com/go/storage"
"firebase.google.com/go/v4"
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/integration/internal"
"firebase.google.com/go/v4/storage"
)
Expand Down
2 changes: 2 additions & 0 deletions snippets/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -1425,6 +1425,8 @@ func verifyIDTokenAndCheckRevokedTenant(ctx context.Context, tenantClient *auth.
if err != nil {
if auth.IsIDTokenRevoked(err) {
// Token is revoked. Inform the user to reauthenticate or signOut() the user.
} else if auth.IsUserDisabled(err) {
// Token is disabled.
} else {
// Token is invalid
}
Expand Down
2 changes: 1 addition & 1 deletion snippets/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
"fmt"
"log"

"firebase.google.com/go/v4"
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/db"
"google.golang.org/api/option"
)
Expand Down
22 changes: 22 additions & 0 deletions testdata/get_disabled_user.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"kind": "identitytoolkit#GetAccountInfoResponse",
"users": [
{
"localId": "testuser",
"email": "[email protected]",
"phoneNumber": "+1234567890",
"emailVerified": true,
"displayName": "Test User",
"photoUrl": "http://www.example.com/testuser/photo.png",
"passwordHash": "passwordhash",
"salt": "salt===",
"passwordUpdatedAt": 1.494364393E+12,
"validSince": "1494364393",
"disabled": true,
"createdAt": "1234567890000",
"lastLoginAt": "1233211232000",
"customAttributes": "{\"admin\": true, \"package\": \"gold\"}",
"tenantId": "testTenant"
}
]
}

0 comments on commit ea909b1

Please sign in to comment.