Fix: Refresh token when id_token is expired (#79569)
* Fix: Refresh token when id_token is expired * add id_token comparison * Fix wire * Use userID as cache key * Apply suggestions from code review --------- Co-authored-by: linoman <2051016+linoman@users.noreply.github.com> Co-authored-by: Misi <mgyongyosi@users.noreply.github.com>
This commit is contained in:
co-authored by
linoman
Misi
parent
62806e8f8c
commit
596e828150
@@ -3,26 +3,20 @@ package sync
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v3/jwt"
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/login/social"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/login"
|
||||
"github.com/grafana/grafana/pkg/services/oauthtoken"
|
||||
)
|
||||
|
||||
func ProvideOAuthTokenSync(service oauthtoken.OAuthTokenService, sessionService auth.UserTokenService, socialService social.Service) *OAuthTokenSync {
|
||||
return &OAuthTokenSync{
|
||||
log.New("oauth_token.sync"),
|
||||
localcache.New(maxOAuthTokenCacheTTL, 15*time.Minute),
|
||||
service,
|
||||
sessionService,
|
||||
socialService,
|
||||
@@ -31,12 +25,11 @@ func ProvideOAuthTokenSync(service oauthtoken.OAuthTokenService, sessionService
|
||||
}
|
||||
|
||||
type OAuthTokenSync struct {
|
||||
log log.Logger
|
||||
cache *localcache.CacheService
|
||||
service oauthtoken.OAuthTokenService
|
||||
sessionService auth.UserTokenService
|
||||
socialService social.Service
|
||||
sf *singleflight.Group
|
||||
log log.Logger
|
||||
service oauthtoken.OAuthTokenService
|
||||
sessionService auth.UserTokenService
|
||||
socialService social.Service
|
||||
singleflightGroup *singleflight.Group
|
||||
}
|
||||
|
||||
func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, identity *authn.Identity, _ *authn.Request) error {
|
||||
@@ -51,71 +44,14 @@ func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, identity *authn
|
||||
return nil
|
||||
}
|
||||
|
||||
// if we recently have performed this it would be cached, so we can skip the hook
|
||||
if _, ok := s.cache.Get(identity.ID); ok {
|
||||
s.log.FromContext(ctx).Debug("OAuth token check is cached", "id", identity.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
token, exists, err := s.service.HasOAuthEntry(ctx, identity)
|
||||
// user is not authenticated through oauth so skip further checks
|
||||
if !exists {
|
||||
if err != nil {
|
||||
s.log.FromContext(ctx).Error("Failed to fetch oauth entry", "id", identity.ID, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
idTokenExpiry, err := getIDTokenExpiry(token)
|
||||
if err != nil {
|
||||
s.log.FromContext(ctx).Error("Failed to extract expiry of ID token", "id", identity.ID, "error", err)
|
||||
}
|
||||
|
||||
// token has no expire time configured, so we don't have to refresh it
|
||||
if token.OAuthExpiry.IsZero() {
|
||||
s.log.FromContext(ctx).Debug("Access token without expiry", "id", identity.ID)
|
||||
// cache the token check, so we don't perform it on every request
|
||||
s.cache.Set(identity.ID, struct{}{}, getOAuthTokenCacheTTL(token.OAuthExpiry, idTokenExpiry))
|
||||
return nil
|
||||
}
|
||||
|
||||
// get the token's auth provider (f.e. azuread)
|
||||
provider := strings.TrimPrefix(token.AuthModule, "oauth_")
|
||||
currentOAuthInfo := s.socialService.GetOAuthInfoProvider(provider)
|
||||
if currentOAuthInfo == nil {
|
||||
s.log.Warn("OAuth provider not found", "provider", provider)
|
||||
return nil
|
||||
}
|
||||
|
||||
// if refresh token handling is disabled for this provider, we can skip the hook
|
||||
if !currentOAuthInfo.UseRefreshToken {
|
||||
return nil
|
||||
}
|
||||
|
||||
accessTokenExpires, hasAccessTokenExpired := getExpiryWithSkew(token.OAuthExpiry)
|
||||
|
||||
hasIdTokenExpired := false
|
||||
idTokenExpires := time.Time{}
|
||||
|
||||
if !idTokenExpiry.IsZero() {
|
||||
idTokenExpires, hasIdTokenExpired = getExpiryWithSkew(idTokenExpiry)
|
||||
}
|
||||
// token has not expired, so we don't have to refresh it
|
||||
if !hasAccessTokenExpired && !hasIdTokenExpired {
|
||||
s.log.FromContext(ctx).Debug("Access and id token has not expired yet", "id", identity.ID)
|
||||
// cache the token check, so we don't perform it on every request
|
||||
s.cache.Set(identity.ID, struct{}{}, getOAuthTokenCacheTTL(accessTokenExpires, idTokenExpires))
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err, _ = s.sf.Do(identity.ID, func() (interface{}, error) {
|
||||
_, err, _ := s.singleflightGroup.Do(identity.ID, func() (interface{}, error) {
|
||||
s.log.Debug("Singleflight request for OAuth token sync", "key", identity.ID)
|
||||
|
||||
// FIXME: Consider using context.WithoutCancel instead of context.Background after Go 1.21 update
|
||||
updateCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if refreshErr := s.service.TryTokenRefresh(updateCtx, token); refreshErr != nil {
|
||||
if refreshErr := s.service.TryTokenRefresh(updateCtx, identity); refreshErr != nil {
|
||||
if errors.Is(refreshErr, context.Canceled) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -153,56 +89,3 @@ func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, identity *authn
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxOAuthTokenCacheTTL = 10 * time.Minute
|
||||
|
||||
func getOAuthTokenCacheTTL(accessTokenExpiry, idTokenExpiry time.Time) time.Duration {
|
||||
if accessTokenExpiry.IsZero() && idTokenExpiry.IsZero() {
|
||||
return maxOAuthTokenCacheTTL
|
||||
}
|
||||
|
||||
min := func(a, b time.Duration) time.Duration {
|
||||
if a <= b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
if accessTokenExpiry.IsZero() && !idTokenExpiry.IsZero() {
|
||||
return min(time.Until(idTokenExpiry), maxOAuthTokenCacheTTL)
|
||||
}
|
||||
|
||||
if !accessTokenExpiry.IsZero() && idTokenExpiry.IsZero() {
|
||||
return min(time.Until(accessTokenExpiry), maxOAuthTokenCacheTTL)
|
||||
}
|
||||
|
||||
return min(min(time.Until(accessTokenExpiry), time.Until(idTokenExpiry)), maxOAuthTokenCacheTTL)
|
||||
}
|
||||
|
||||
// getIDTokenExpiry extracts the expiry time from the ID token
|
||||
func getIDTokenExpiry(token *login.UserAuth) (time.Time, error) {
|
||||
if token.OAuthIdToken == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
parsedToken, err := jwt.ParseSigned(token.OAuthIdToken)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("error parsing id token: %w", err)
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
var claims Claims
|
||||
if err := parsedToken.UnsafeClaimsWithoutVerification(&claims); err != nil {
|
||||
return time.Time{}, fmt.Errorf("error getting claims from id token: %w", err)
|
||||
}
|
||||
|
||||
return time.Unix(claims.Exp, 0), nil
|
||||
}
|
||||
|
||||
func getExpiryWithSkew(expiry time.Time) (adjustedExpiry time.Time, hasTokenExpired bool) {
|
||||
adjustedExpiry = expiry.Round(0).Add(-oauthtoken.ExpiryDelta)
|
||||
hasTokenExpired = adjustedExpiry.Before(time.Now())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,18 +2,13 @@ package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/login/social"
|
||||
"github.com/grafana/grafana/pkg/login/social/socialtest"
|
||||
@@ -45,45 +40,17 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) {
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
desc: "should skip sync when identity is not a user",
|
||||
identity: &authn.Identity{ID: "service-account:1"},
|
||||
desc: "should skip sync when identity is not a user",
|
||||
identity: &authn.Identity{ID: "service-account:1"},
|
||||
expectTryRefreshTokenCalled: false,
|
||||
},
|
||||
{
|
||||
desc: "should skip sync when identity is a user but is not authenticated with session token",
|
||||
identity: &authn.Identity{ID: "user:1"},
|
||||
desc: "should skip sync when identity is a user but is not authenticated with session token",
|
||||
identity: &authn.Identity{ID: "user:1"},
|
||||
expectTryRefreshTokenCalled: false,
|
||||
},
|
||||
{
|
||||
desc: "should skip sync when user has session but is not authenticated with oauth",
|
||||
identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}},
|
||||
expectHasEntryCalled: true,
|
||||
},
|
||||
{
|
||||
desc: "should skip sync for when access token don't have expire time",
|
||||
identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}},
|
||||
expectHasEntryCalled: true,
|
||||
expectedHasEntryToken: &login.UserAuth{},
|
||||
},
|
||||
{
|
||||
desc: "should skip sync when access token has no expired yet",
|
||||
identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}},
|
||||
expectHasEntryCalled: true,
|
||||
expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(10 * time.Minute)},
|
||||
},
|
||||
{
|
||||
desc: "should skip sync when access token has no expired yet",
|
||||
identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}},
|
||||
expectHasEntryCalled: true,
|
||||
expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(10 * time.Minute)},
|
||||
},
|
||||
{
|
||||
desc: "should refresh access token when it has expired",
|
||||
identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}},
|
||||
expectHasEntryCalled: true,
|
||||
expectTryRefreshTokenCalled: true,
|
||||
expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(-10 * time.Minute)},
|
||||
},
|
||||
{
|
||||
desc: "should invalidate access token and session token if access token can't be refreshed",
|
||||
desc: "should invalidate access token and session token if token refresh fails",
|
||||
identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}},
|
||||
expectHasEntryCalled: true,
|
||||
expectedTryRefreshErr: errors.New("some err"),
|
||||
@@ -92,21 +59,27 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) {
|
||||
expectRevokeTokenCalled: true,
|
||||
expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(-10 * time.Minute)},
|
||||
expectedErr: authn.ErrExpiredAccessToken,
|
||||
}, {
|
||||
desc: "should skip sync when use_refresh_token is disabled",
|
||||
identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}, AuthenticatedBy: login.GitLabAuthModule},
|
||||
expectHasEntryCalled: true,
|
||||
expectTryRefreshTokenCalled: false,
|
||||
expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(-10 * time.Minute)},
|
||||
oauthInfo: &social.OAuthInfo{UseRefreshToken: false},
|
||||
},
|
||||
{
|
||||
desc: "should refresh access token when ID token has expired",
|
||||
identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}},
|
||||
expectHasEntryCalled: true,
|
||||
expectTryRefreshTokenCalled: true,
|
||||
expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(10 * time.Minute), OAuthIdToken: fakeIDToken(t, time.Now().Add(-10*time.Minute))},
|
||||
desc: "should refresh the token successfully",
|
||||
identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}},
|
||||
expectHasEntryCalled: false,
|
||||
expectTryRefreshTokenCalled: true,
|
||||
expectInvalidateOauthTokensCalled: false,
|
||||
expectRevokeTokenCalled: false,
|
||||
},
|
||||
{
|
||||
desc: "should not invalidate the token if the token has already been refreshed by another request (singleflight)",
|
||||
identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}},
|
||||
expectHasEntryCalled: true,
|
||||
expectTryRefreshTokenCalled: true,
|
||||
expectInvalidateOauthTokensCalled: false,
|
||||
expectRevokeTokenCalled: false,
|
||||
expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(10 * time.Minute)},
|
||||
expectedTryRefreshErr: errors.New("some err"),
|
||||
},
|
||||
|
||||
// TODO: address coverage of oauthtoken sync
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -127,7 +100,7 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) {
|
||||
invalidateTokensCalled = true
|
||||
return nil
|
||||
},
|
||||
TryTokenRefreshFunc: func(ctx context.Context, usr *login.UserAuth) error {
|
||||
TryTokenRefreshFunc: func(ctx context.Context, usr identity.Requester) error {
|
||||
tryRefreshCalled = true
|
||||
return tt.expectedTryRefreshErr
|
||||
},
|
||||
@@ -151,12 +124,11 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) {
|
||||
}
|
||||
|
||||
sync := &OAuthTokenSync{
|
||||
log: log.NewNopLogger(),
|
||||
cache: localcache.New(0, 0),
|
||||
service: service,
|
||||
sessionService: sessionService,
|
||||
socialService: socialService,
|
||||
sf: new(singleflight.Group),
|
||||
log: log.NewNopLogger(),
|
||||
service: service,
|
||||
sessionService: sessionService,
|
||||
socialService: socialService,
|
||||
singleflightGroup: new(singleflight.Group),
|
||||
}
|
||||
|
||||
err := sync.SyncOauthTokenHook(context.Background(), tt.identity, nil)
|
||||
@@ -168,93 +140,3 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeIDToken is used to create a fake invalid token to verify expiry logic
|
||||
func fakeIDToken(t *testing.T, expiryDate time.Time) string {
|
||||
type Header struct {
|
||||
Kid string `json:"kid"`
|
||||
Alg string `json:"alg"`
|
||||
}
|
||||
type Payload struct {
|
||||
Iss string `json:"iss"`
|
||||
Sub string `json:"sub"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
header, err := json.Marshal(Header{Kid: "123", Alg: "none"})
|
||||
require.NoError(t, err)
|
||||
u := expiryDate.UTC().Unix()
|
||||
payload, err := json.Marshal(Payload{Iss: "fake", Sub: "a-sub", Exp: u})
|
||||
require.NoError(t, err)
|
||||
|
||||
fakeSignature := []byte("6ICJm")
|
||||
return fmt.Sprintf("%s.%s.%s", base64.RawURLEncoding.EncodeToString(header), base64.RawURLEncoding.EncodeToString(payload), base64.RawURLEncoding.EncodeToString(fakeSignature))
|
||||
}
|
||||
|
||||
func TestOAuthTokenSync_getOAuthTokenCacheTTL(t *testing.T) {
|
||||
defaultTime := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
accessTokenExpiry time.Time
|
||||
idTokenExpiry time.Time
|
||||
want time.Duration
|
||||
}{
|
||||
{
|
||||
name: "should return maxOAuthTokenCacheTTL when no expiry is given",
|
||||
accessTokenExpiry: time.Time{},
|
||||
idTokenExpiry: time.Time{},
|
||||
|
||||
want: maxOAuthTokenCacheTTL,
|
||||
},
|
||||
{
|
||||
name: "should return maxOAuthTokenCacheTTL when access token is not given and id token expiry is greater than max cache ttl",
|
||||
accessTokenExpiry: time.Time{},
|
||||
idTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL),
|
||||
|
||||
want: maxOAuthTokenCacheTTL,
|
||||
},
|
||||
{
|
||||
name: "should return idTokenExpiry when access token is not given and id token expiry is less than max cache ttl",
|
||||
accessTokenExpiry: time.Time{},
|
||||
idTokenExpiry: defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL),
|
||||
want: time.Until(defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL)),
|
||||
},
|
||||
{
|
||||
name: "should return maxOAuthTokenCacheTTL when access token expiry is greater than max cache ttl and id token is not given",
|
||||
accessTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL),
|
||||
idTokenExpiry: time.Time{},
|
||||
want: maxOAuthTokenCacheTTL,
|
||||
},
|
||||
{
|
||||
name: "should return accessTokenExpiry when access token expiry is less than max cache ttl and id token is not given",
|
||||
accessTokenExpiry: defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL),
|
||||
idTokenExpiry: time.Time{},
|
||||
want: time.Until(defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL)),
|
||||
},
|
||||
{
|
||||
name: "should return accessTokenExpiry when access token expiry is less than max cache ttl and less than id token expiry",
|
||||
accessTokenExpiry: defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL),
|
||||
idTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL),
|
||||
want: time.Until(defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL)),
|
||||
},
|
||||
{
|
||||
name: "should return idTokenExpiry when id token expiry is less than max cache ttl and less than access token expiry",
|
||||
accessTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL),
|
||||
idTokenExpiry: defaultTime.Add(-3*time.Minute + maxOAuthTokenCacheTTL),
|
||||
want: time.Until(defaultTime.Add(-3*time.Minute + maxOAuthTokenCacheTTL)),
|
||||
},
|
||||
{
|
||||
name: "should return maxOAuthTokenCacheTTL when access token expiry is greater than max cache ttl and id token expiry is greater than max cache ttl",
|
||||
accessTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL),
|
||||
idTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL),
|
||||
want: maxOAuthTokenCacheTTL,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := getOAuthTokenCacheTTL(tt.accessTokenExpiry, tt.idTokenExpiry)
|
||||
|
||||
assert.Equal(t, tt.want.Round(time.Second), got.Round(time.Second))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user