Auth: Refresh OAuth access_token automatically using the refresh_token (#56076)

* Verify OAuth token expiration for oauth users in the ctx handler middleware

* Use refresh token to get a new access token

* Refactor oauth_token.go

* Add tests for the middleware changes

* Align other tests

* Add tests, wip

* Add more tests

* Add InvalidateOAuthTokens method

* Fix ExpiryDate update to default

* Invalidate OAuth tokens during logout

* Improve logout

* Add more comments

* Cleanup

* Fix import order

* Add error to HasOAuthEntry return values

* add dev debug logs

* Fix tests

Co-authored-by: jguer <joao.guerreiro@grafana.com>
This commit is contained in:
Misi
2022-10-18 18:17:28 +02:00
committed by GitHub
co-authored by jguer
parent 984ec00aac
commit 9c954d06ab
17 changed files with 828 additions and 89 deletions
+147 -50
View File
@@ -3,8 +3,12 @@ package oauthtoken
import (
"context"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/oauth2"
"golang.org/x/sync/singleflight"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/login/social"
@@ -12,26 +16,39 @@ import (
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
var (
logger = log.New("oauthtoken")
// ExpiryDelta is used to prevent any issue that is caused by the clock skew (server times can differ slightly between different machines).
// Shouldn't be more than 30s
ExpiryDelta = 10 * time.Second
ErrNoRefreshTokenFound = errors.New("no refresh token found")
ErrNotAnOAuthProvider = errors.New("not an oauth provider")
)
type Service struct {
SocialService social.Service
AuthInfoService login.AuthInfoService
Cfg *setting.Cfg
SocialService social.Service
AuthInfoService login.AuthInfoService
singleFlightGroup *singleflight.Group
}
type OAuthTokenService interface {
GetCurrentOAuthToken(context.Context, *user.SignedInUser) *oauth2.Token
IsOAuthPassThruEnabled(*datasources.DataSource) bool
HasOAuthEntry(context.Context, *user.SignedInUser) (*models.UserAuth, bool, error)
TryTokenRefresh(context.Context, *models.UserAuth) error
InvalidateOAuthTokens(context.Context, *models.UserAuth) error
}
func ProvideService(socialService social.Service, authInfoService login.AuthInfoService) *Service {
func ProvideService(socialService social.Service, authInfoService login.AuthInfoService, cfg *setting.Cfg) *Service {
return &Service{
SocialService: socialService,
AuthInfoService: authInfoService,
Cfg: cfg,
SocialService: socialService,
AuthInfoService: authInfoService,
singleFlightGroup: new(singleflight.Group),
}
}
@@ -46,59 +63,17 @@ func (o *Service) GetCurrentOAuthToken(ctx context.Context, usr *user.SignedInUs
if err := o.AuthInfoService.GetAuthInfo(ctx, authInfoQuery); err != nil {
if errors.Is(err, user.ErrUserNotFound) {
// Not necessarily an error. User may be logged in another way.
logger.Debug("no OAuth token for user found", "userId", usr.UserID, "username", usr.Login)
logger.Debug("no oauth token for user found", "userId", usr.UserID, "username", usr.Login)
} else {
logger.Error("failed to get OAuth token for user", "userId", usr.UserID, "username", usr.Login, "error", err)
logger.Error("failed to get oauth token for user", "userId", usr.UserID, "username", usr.Login, "error", err)
}
return nil
}
authProvider := authInfoQuery.Result.AuthModule
connect, err := o.SocialService.GetConnector(authProvider)
token, err := o.tryGetOrRefreshAccessToken(ctx, authInfoQuery.Result)
if err != nil {
logger.Error("failed to get OAuth connector", "provider", authProvider, "error", err)
return nil
}
client, err := o.SocialService.GetOAuthHttpClient(authProvider)
if err != nil {
logger.Error("failed to get OAuth http client", "provider", authProvider, "error", err)
return nil
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, client)
persistedToken := &oauth2.Token{
AccessToken: authInfoQuery.Result.OAuthAccessToken,
Expiry: authInfoQuery.Result.OAuthExpiry,
RefreshToken: authInfoQuery.Result.OAuthRefreshToken,
TokenType: authInfoQuery.Result.OAuthTokenType,
}
if authInfoQuery.Result.OAuthIdToken != "" {
persistedToken = persistedToken.WithExtra(map[string]interface{}{"id_token": authInfoQuery.Result.OAuthIdToken})
}
// TokenSource handles refreshing the token if it has expired
token, err := connect.TokenSource(ctx, persistedToken).Token()
if err != nil {
logger.Error("failed to retrieve OAuth access token", "provider", authInfoQuery.Result.AuthModule, "userId", usr.UserID, "username", usr.Login, "error", err)
return nil
}
// If the tokens are not the same, update the entry in the DB
if !tokensEq(persistedToken, token) {
updateAuthCommand := &models.UpdateAuthInfoCommand{
UserId: authInfoQuery.Result.UserId,
AuthModule: authInfoQuery.Result.AuthModule,
AuthId: authInfoQuery.Result.AuthId,
OAuthToken: token,
}
if err := o.AuthInfoService.UpdateAuthInfo(ctx, updateAuthCommand); err != nil {
logger.Error("failed to update auth info during token refresh", "userId", usr.UserID, "username", usr.Login, "error", err)
return nil
}
logger.Debug("updated OAuth info for user", "userId", usr.UserID, "username", usr.Login)
}
return token
}
@@ -107,6 +82,128 @@ func (o *Service) IsOAuthPassThruEnabled(ds *datasources.DataSource) bool {
return ds.JsonData != nil && ds.JsonData.Get("oauthPassThru").MustBool()
}
// HasOAuthEntry returns true and the UserAuth object when OAuth info exists for the specified User
func (o *Service) HasOAuthEntry(ctx context.Context, usr *user.SignedInUser) (*models.UserAuth, bool, error) {
if usr == nil {
// No user, therefore no token
return nil, false, nil
}
authInfoQuery := &models.GetAuthInfoQuery{UserId: usr.UserID}
err := o.AuthInfoService.GetAuthInfo(ctx, authInfoQuery)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
// Not necessarily an error. User may be logged in another way.
return nil, false, nil
}
logger.Error("failed to fetch oauth token for user", "userId", usr.UserID, "username", usr.Login, "error", err)
return nil, false, err
}
if !strings.Contains(authInfoQuery.Result.AuthModule, "oauth") {
return nil, false, nil
}
return authInfoQuery.Result, true, nil
}
// TryTokenRefresh returns an error in case the OAuth token refresh was unsuccessful
// It uses a singleflight.Group to prevent getting the Refresh Token multiple times for a given User
func (o *Service) TryTokenRefresh(ctx context.Context, usr *models.UserAuth) error {
lockKey := fmt.Sprintf("oauth-refresh-token-%d", usr.UserId)
_, err, _ := o.singleFlightGroup.Do(lockKey, func() (interface{}, error) {
logger.Debug("singleflight request for getting a new access token", "key", lockKey)
authProvider := usr.AuthModule
if !strings.Contains(authProvider, "oauth") {
logger.Error("the specified user's auth provider is not oauth", "authmodule", usr.AuthModule, "userid", usr.UserId)
return nil, ErrNotAnOAuthProvider
}
if usr.OAuthRefreshToken == "" {
logger.Debug("no refresh token available", "authmodule", usr.AuthModule, "userid", usr.UserId)
return nil, ErrNoRefreshTokenFound
}
return o.tryGetOrRefreshAccessToken(ctx, usr)
})
return err
}
// InvalidateOAuthTokens invalidates the OAuth tokens (access_token, refresh_token) and sets the Expiry to default/zero
func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr *models.UserAuth) error {
return o.AuthInfoService.UpdateAuthInfo(ctx, &models.UpdateAuthInfoCommand{
UserId: usr.UserId,
AuthModule: usr.AuthModule,
AuthId: usr.AuthId,
OAuthToken: &oauth2.Token{
AccessToken: "",
RefreshToken: "",
Expiry: time.Time{},
},
})
}
func (o *Service) tryGetOrRefreshAccessToken(ctx context.Context, usr *models.UserAuth) (*oauth2.Token, error) {
authProvider := usr.AuthModule
connect, err := o.SocialService.GetConnector(authProvider)
if err != nil {
logger.Error("failed to get oauth connector", "provider", authProvider, "error", err)
return nil, err
}
client, err := o.SocialService.GetOAuthHttpClient(authProvider)
if err != nil {
logger.Error("failed to get oauth http client", "provider", authProvider, "error", err)
return nil, err
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, client)
persistedToken := &oauth2.Token{
AccessToken: usr.OAuthAccessToken,
Expiry: usr.OAuthExpiry,
RefreshToken: usr.OAuthRefreshToken,
TokenType: usr.OAuthTokenType,
}
if usr.OAuthIdToken != "" {
persistedToken = persistedToken.WithExtra(map[string]interface{}{"id_token": usr.OAuthIdToken})
}
// TokenSource handles refreshing the token if it has expired
token, err := connect.TokenSource(ctx, persistedToken).Token()
if err != nil {
logger.Error("failed to retrieve oauth access token", "provider", usr.AuthModule, "userId", usr.UserId, "error", err)
return nil, err
}
// If the tokens are not the same, update the entry in the DB
if !tokensEq(persistedToken, token) {
updateAuthCommand := &models.UpdateAuthInfoCommand{
UserId: usr.UserId,
AuthModule: usr.AuthModule,
AuthId: usr.AuthId,
OAuthToken: token,
}
if o.Cfg.Env == setting.Dev {
logger.Debug("oauth got token",
"user", usr.UserId,
"auth_module", usr.AuthModule,
"expiry", fmt.Sprintf("%v", token.Expiry),
"access_token", fmt.Sprintf("%v", token.AccessToken),
"refresh_token", fmt.Sprintf("%v", token.RefreshToken),
)
}
if err := o.AuthInfoService.UpdateAuthInfo(ctx, updateAuthCommand); err != nil {
logger.Error("failed to update auth info during token refresh", "userId", usr.UserId, "error", err)
return nil, err
}
logger.Debug("updated oauth info for user", "userId", usr.UserId)
}
return token, nil
}
// tokensEq checks for OAuth2 token equivalence given the fields of the struct Grafana is interested in
func tokensEq(t1, t2 *oauth2.Token) bool {
return t1.AccessToken == t2.AccessToken &&