OAuth: Use the attached external session data in OAuthToken and OAuthTokenSync (#96655)

* wip

* wip + tests

* wip

* wip opt2

* Use authn.Identity struct's SessionToken

* Merge fixes

* Handle disabling the feature flag correctly

* Fix test

* Cleanup

* Remove HasOAuthEntry from the OAuthTokenService interface

* Remove unused function
This commit is contained in:
Misi
2024-11-27 11:06:39 +01:00
committed by GitHub
parent 6e2d3cae5e
commit 84b8296ffb
27 changed files with 1032 additions and 310 deletions
+5 -2
View File
@@ -19,6 +19,7 @@ import (
glog "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/contexthandler"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -57,7 +58,8 @@ type httpClient interface {
func NewDataSourceProxy(ds *datasources.DataSource, pluginRoutes []*plugins.Route, ctx *contextmodel.ReqContext,
proxyPath string, cfg *setting.Cfg, clientProvider httpclient.Provider,
oAuthTokenService oauthtoken.OAuthTokenService, dsService datasources.DataSourceService,
tracer tracing.Tracer, features featuremgmt.FeatureToggles) (*DataSourceProxy, error) {
tracer tracing.Tracer, features featuremgmt.FeatureToggles,
) (*DataSourceProxy, error) {
targetURL, err := datasource.ValidateURL(ds.Type, ds.URL)
if err != nil {
return nil, err
@@ -261,7 +263,8 @@ func (proxy *DataSourceProxy) director(req *http.Request) {
}
if proxy.oAuthTokenService.IsOAuthPassThruEnabled(proxy.ds) {
if token := proxy.oAuthTokenService.GetCurrentOAuthToken(req.Context(), proxy.ctx.SignedInUser); token != nil {
reqCtx := contexthandler.FromContext(req.Context())
if token := proxy.oAuthTokenService.GetCurrentOAuthToken(req.Context(), proxy.ctx.SignedInUser, reqCtx.UserToken); token != nil {
req.Header.Set("Authorization", fmt.Sprintf("%s %s", token.Type(), token.AccessToken))
idToken, ok := token.Extra("id_token").(string)
+4 -1
View File
@@ -32,7 +32,9 @@ import (
pluginfakes "github.com/grafana/grafana/pkg/plugins/manager/fakes"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/datasources"
datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service"
@@ -557,7 +559,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
var routes []*plugins.Route
proxy, err := setupDSProxyTest(t, ctx, ds, routes, "/path/to/folder/", func(proxy *DataSourceProxy) {
proxy.oAuthTokenService = &oauthtokentest.MockOauthTokenService{
GetCurrentOauthTokenFunc: func(_ context.Context, _ identity.Requester) *oauth2.Token {
GetCurrentOauthTokenFunc: func(_ context.Context, _ identity.Requester, _ *auth.UserToken) *oauth2.Token {
return (&oauth2.Token{
AccessToken: "testtoken",
RefreshToken: "testrefreshtoken",
@@ -573,6 +575,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
require.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
req = req.WithContext(context.WithValue(req.Context(), ctxkey.Key{}, &contextmodel.ReqContext{UserToken: nil}))
require.NoError(t, err)
proxy.director(req)
+4 -1
View File
@@ -74,12 +74,15 @@ type CreateTokenCommand struct {
}
// UserTokenService are used for generating and validating user tokens
//
//go:generate mockery --name UserTokenService --structname MockUserAuthTokenService --outpkg authtest --filename auth_token_service_mock.go --output ./authtest/
type UserTokenService interface {
CreateToken(ctx context.Context, cmd *CreateTokenCommand) (*UserToken, error)
LookupToken(ctx context.Context, unhashedToken string) (*UserToken, error)
GetTokenByExternalSessionID(ctx context.Context, externalSessionID int64) (*UserToken, error)
GetExternalSession(ctx context.Context, extSessionID int64) (*ExternalSession, error)
GetExternalSession(ctx context.Context, externalSessionID int64) (*ExternalSession, error)
FindExternalSessions(ctx context.Context, query *ListExternalSessionQuery) ([]*ExternalSession, error)
UpdateExternalSession(ctx context.Context, externalSessionID int64, cmd *UpdateExternalSessionCommand) error
// RotateToken will always rotate a valid token
RotateToken(ctx context.Context, cmd RotateCommand) (*UserToken, error)
+6 -2
View File
@@ -248,14 +248,18 @@ func (s *UserAuthTokenService) GetTokenByExternalSessionID(ctx context.Context,
return &userToken, err
}
func (s *UserAuthTokenService) GetExternalSession(ctx context.Context, extSessionID int64) (*auth.ExternalSession, error) {
return s.externalSessionStore.Get(ctx, extSessionID)
func (s *UserAuthTokenService) GetExternalSession(ctx context.Context, externalSessionID int64) (*auth.ExternalSession, error) {
return s.externalSessionStore.Get(ctx, externalSessionID)
}
func (s *UserAuthTokenService) FindExternalSessions(ctx context.Context, query *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error) {
return s.externalSessionStore.List(ctx, query)
}
func (s *UserAuthTokenService) UpdateExternalSession(ctx context.Context, externalSessionID int64, cmd *auth.UpdateExternalSessionCommand) error {
return s.externalSessionStore.Update(ctx, externalSessionID, cmd)
}
func (s *UserAuthTokenService) RotateToken(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error) {
if cmd.UnHashedToken == "" {
return nil, auth.ErrInvalidSessionToken
@@ -27,11 +27,11 @@ func provideExternalSessionStore(sqlStore db.DB, secretService secrets.Service,
}
}
func (s *store) Get(ctx context.Context, extSessionID int64) (*auth.ExternalSession, error) {
func (s *store) Get(ctx context.Context, ID int64) (*auth.ExternalSession, error) {
ctx, span := s.tracer.Start(ctx, "externalsession.Get")
defer span.End()
externalSession := &auth.ExternalSession{ID: extSessionID}
externalSession := &auth.ExternalSession{ID: ID}
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
found, err := sess.Get(externalSession)
@@ -150,6 +150,45 @@ func (s *store) Create(ctx context.Context, extSession *auth.ExternalSession) er
return nil
}
func (s *store) Update(ctx context.Context, ID int64, cmd *auth.UpdateExternalSessionCommand) error {
ctx, span := s.tracer.Start(ctx, "externalsession.Update")
defer span.End()
var err error
externalSession := &auth.ExternalSession{}
externalSession.AccessToken, err = s.encryptAndEncode(cmd.Token.AccessToken)
if err != nil {
return err
}
externalSession.RefreshToken, err = s.encryptAndEncode(cmd.Token.RefreshToken)
if err != nil {
return err
}
var secretIdToken string
if idToken, ok := cmd.Token.Extra("id_token").(string); ok && idToken != "" {
secretIdToken, err = s.encryptAndEncode(idToken)
if err != nil {
return err
}
externalSession.IDToken = secretIdToken
}
externalSession.ExpiresAt = cmd.Token.Expiry
err = s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
_, err := sess.ID(ID).Cols("access_token", "refresh_token", "id_token", "expires_at").Update(externalSession)
return err
})
if err != nil {
return err
}
return nil
}
func (s *store) Delete(ctx context.Context, ID int64) error {
ctx, span := s.tracer.Start(ctx, "externalsession.Delete")
defer span.End()
@@ -0,0 +1,384 @@
// Code generated by mockery v2.42.1. DO NOT EDIT.
package authtest
import (
context "context"
auth "github.com/grafana/grafana/pkg/services/auth"
mock "github.com/stretchr/testify/mock"
usertoken "github.com/grafana/grafana/pkg/models/usertoken"
)
// MockUserAuthTokenService is an autogenerated mock type for the UserTokenService type
type MockUserAuthTokenService struct {
mock.Mock
}
// ActiveTokenCount provides a mock function with given fields: ctx, userID
func (_m *MockUserAuthTokenService) ActiveTokenCount(ctx context.Context, userID *int64) (int64, error) {
ret := _m.Called(ctx, userID)
if len(ret) == 0 {
panic("no return value specified for ActiveTokenCount")
}
var r0 int64
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *int64) (int64, error)); ok {
return rf(ctx, userID)
}
if rf, ok := ret.Get(0).(func(context.Context, *int64) int64); ok {
r0 = rf(ctx, userID)
} else {
r0 = ret.Get(0).(int64)
}
if rf, ok := ret.Get(1).(func(context.Context, *int64) error); ok {
r1 = rf(ctx, userID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// CreateToken provides a mock function with given fields: ctx, cmd
func (_m *MockUserAuthTokenService) CreateToken(ctx context.Context, cmd *auth.CreateTokenCommand) (*usertoken.UserToken, error) {
ret := _m.Called(ctx, cmd)
if len(ret) == 0 {
panic("no return value specified for CreateToken")
}
var r0 *usertoken.UserToken
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *auth.CreateTokenCommand) (*usertoken.UserToken, error)); ok {
return rf(ctx, cmd)
}
if rf, ok := ret.Get(0).(func(context.Context, *auth.CreateTokenCommand) *usertoken.UserToken); ok {
r0 = rf(ctx, cmd)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*usertoken.UserToken)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *auth.CreateTokenCommand) error); ok {
r1 = rf(ctx, cmd)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindExternalSessions provides a mock function with given fields: ctx, query
func (_m *MockUserAuthTokenService) FindExternalSessions(ctx context.Context, query *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error) {
ret := _m.Called(ctx, query)
if len(ret) == 0 {
panic("no return value specified for FindExternalSessions")
}
var r0 []*auth.ExternalSession
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error)); ok {
return rf(ctx, query)
}
if rf, ok := ret.Get(0).(func(context.Context, *auth.ListExternalSessionQuery) []*auth.ExternalSession); ok {
r0 = rf(ctx, query)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*auth.ExternalSession)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *auth.ListExternalSessionQuery) error); ok {
r1 = rf(ctx, query)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetExternalSession provides a mock function with given fields: ctx, externalSessionID
func (_m *MockUserAuthTokenService) GetExternalSession(ctx context.Context, externalSessionID int64) (*auth.ExternalSession, error) {
ret := _m.Called(ctx, externalSessionID)
if len(ret) == 0 {
panic("no return value specified for GetExternalSession")
}
var r0 *auth.ExternalSession
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, int64) (*auth.ExternalSession, error)); ok {
return rf(ctx, externalSessionID)
}
if rf, ok := ret.Get(0).(func(context.Context, int64) *auth.ExternalSession); ok {
r0 = rf(ctx, externalSessionID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*auth.ExternalSession)
}
}
if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
r1 = rf(ctx, externalSessionID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetTokenByExternalSessionID provides a mock function with given fields: ctx, externalSessionID
func (_m *MockUserAuthTokenService) GetTokenByExternalSessionID(ctx context.Context, externalSessionID int64) (*usertoken.UserToken, error) {
ret := _m.Called(ctx, externalSessionID)
if len(ret) == 0 {
panic("no return value specified for GetTokenByExternalSessionID")
}
var r0 *usertoken.UserToken
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, int64) (*usertoken.UserToken, error)); ok {
return rf(ctx, externalSessionID)
}
if rf, ok := ret.Get(0).(func(context.Context, int64) *usertoken.UserToken); ok {
r0 = rf(ctx, externalSessionID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*usertoken.UserToken)
}
}
if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
r1 = rf(ctx, externalSessionID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetUserRevokedTokens provides a mock function with given fields: ctx, userID
func (_m *MockUserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userID int64) ([]*usertoken.UserToken, error) {
ret := _m.Called(ctx, userID)
if len(ret) == 0 {
panic("no return value specified for GetUserRevokedTokens")
}
var r0 []*usertoken.UserToken
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, int64) ([]*usertoken.UserToken, error)); ok {
return rf(ctx, userID)
}
if rf, ok := ret.Get(0).(func(context.Context, int64) []*usertoken.UserToken); ok {
r0 = rf(ctx, userID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*usertoken.UserToken)
}
}
if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
r1 = rf(ctx, userID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetUserToken provides a mock function with given fields: ctx, userID, userTokenID
func (_m *MockUserAuthTokenService) GetUserToken(ctx context.Context, userID int64, userTokenID int64) (*usertoken.UserToken, error) {
ret := _m.Called(ctx, userID, userTokenID)
if len(ret) == 0 {
panic("no return value specified for GetUserToken")
}
var r0 *usertoken.UserToken
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, int64, int64) (*usertoken.UserToken, error)); ok {
return rf(ctx, userID, userTokenID)
}
if rf, ok := ret.Get(0).(func(context.Context, int64, int64) *usertoken.UserToken); ok {
r0 = rf(ctx, userID, userTokenID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*usertoken.UserToken)
}
}
if rf, ok := ret.Get(1).(func(context.Context, int64, int64) error); ok {
r1 = rf(ctx, userID, userTokenID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetUserTokens provides a mock function with given fields: ctx, userID
func (_m *MockUserAuthTokenService) GetUserTokens(ctx context.Context, userID int64) ([]*usertoken.UserToken, error) {
ret := _m.Called(ctx, userID)
if len(ret) == 0 {
panic("no return value specified for GetUserTokens")
}
var r0 []*usertoken.UserToken
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, int64) ([]*usertoken.UserToken, error)); ok {
return rf(ctx, userID)
}
if rf, ok := ret.Get(0).(func(context.Context, int64) []*usertoken.UserToken); ok {
r0 = rf(ctx, userID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*usertoken.UserToken)
}
}
if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
r1 = rf(ctx, userID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// LookupToken provides a mock function with given fields: ctx, unhashedToken
func (_m *MockUserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*usertoken.UserToken, error) {
ret := _m.Called(ctx, unhashedToken)
if len(ret) == 0 {
panic("no return value specified for LookupToken")
}
var r0 *usertoken.UserToken
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (*usertoken.UserToken, error)); ok {
return rf(ctx, unhashedToken)
}
if rf, ok := ret.Get(0).(func(context.Context, string) *usertoken.UserToken); ok {
r0 = rf(ctx, unhashedToken)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*usertoken.UserToken)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, unhashedToken)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// RevokeAllUserTokens provides a mock function with given fields: ctx, userID
func (_m *MockUserAuthTokenService) RevokeAllUserTokens(ctx context.Context, userID int64) error {
ret := _m.Called(ctx, userID)
if len(ret) == 0 {
panic("no return value specified for RevokeAllUserTokens")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok {
r0 = rf(ctx, userID)
} else {
r0 = ret.Error(0)
}
return r0
}
// RevokeToken provides a mock function with given fields: ctx, token, soft
func (_m *MockUserAuthTokenService) RevokeToken(ctx context.Context, token *usertoken.UserToken, soft bool) error {
ret := _m.Called(ctx, token, soft)
if len(ret) == 0 {
panic("no return value specified for RevokeToken")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *usertoken.UserToken, bool) error); ok {
r0 = rf(ctx, token, soft)
} else {
r0 = ret.Error(0)
}
return r0
}
// RotateToken provides a mock function with given fields: ctx, cmd
func (_m *MockUserAuthTokenService) RotateToken(ctx context.Context, cmd auth.RotateCommand) (*usertoken.UserToken, error) {
ret := _m.Called(ctx, cmd)
if len(ret) == 0 {
panic("no return value specified for RotateToken")
}
var r0 *usertoken.UserToken
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, auth.RotateCommand) (*usertoken.UserToken, error)); ok {
return rf(ctx, cmd)
}
if rf, ok := ret.Get(0).(func(context.Context, auth.RotateCommand) *usertoken.UserToken); ok {
r0 = rf(ctx, cmd)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*usertoken.UserToken)
}
}
if rf, ok := ret.Get(1).(func(context.Context, auth.RotateCommand) error); ok {
r1 = rf(ctx, cmd)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UpdateExternalSession provides a mock function with given fields: ctx, externalSessionID, cmd
func (_m *MockUserAuthTokenService) UpdateExternalSession(ctx context.Context, externalSessionID int64, cmd *auth.UpdateExternalSessionCommand) error {
ret := _m.Called(ctx, externalSessionID, cmd)
if len(ret) == 0 {
panic("no return value specified for UpdateExternalSession")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int64, *auth.UpdateExternalSessionCommand) error); ok {
r0 = rf(ctx, externalSessionID, cmd)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewMockUserAuthTokenService creates a new instance of MockUserAuthTokenService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockUserAuthTokenService(t interface {
mock.TestingT
Cleanup(func())
}) *MockUserAuthTokenService {
mock := &MockUserAuthTokenService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -147,6 +147,24 @@ func (_m *MockExternalSessionStore) List(ctx context.Context, query *auth.ListEx
return r0, r1
}
// Update provides a mock function with given fields: ctx, ID, cmd
func (_m *MockExternalSessionStore) Update(ctx context.Context, ID int64, cmd *auth.UpdateExternalSessionCommand) error {
ret := _m.Called(ctx, ID, cmd)
if len(ret) == 0 {
panic("no return value specified for Update")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int64, *auth.UpdateExternalSessionCommand) error); ok {
r0 = rf(ctx, ID, cmd)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewMockExternalSessionStore creates a new instance of MockExternalSessionStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockExternalSessionStore(t interface {
+7 -10
View File
@@ -14,12 +14,15 @@ import (
"github.com/grafana/grafana/pkg/services/login"
)
var _ auth.UserTokenService = (*FakeUserAuthTokenService)(nil)
type FakeUserAuthTokenService struct {
CreateTokenProvider func(ctx context.Context, cmd *auth.CreateTokenCommand) (*auth.UserToken, error)
RotateTokenProvider func(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error)
GetTokenByExternalSessionIDProvider func(ctx context.Context, externalSessionID int64) (*auth.UserToken, error)
GetExternalSessionProvider func(ctx context.Context, externalSessionID int64) (*auth.ExternalSession, error)
FindExternalSessionsProvider func(ctx context.Context, query *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error)
UpdateExternalSessionProvider func(ctx context.Context, externalSessionID int64, cmd *auth.UpdateExternalSessionCommand) error
TryRotateTokenProvider func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, *auth.UserToken, error)
LookupTokenProvider func(ctx context.Context, unhashedToken string) (*auth.UserToken, error)
RevokeTokenProvider func(ctx context.Context, token *auth.UserToken, soft bool) error
@@ -98,6 +101,10 @@ func (s *FakeUserAuthTokenService) FindExternalSessions(ctx context.Context, que
return s.FindExternalSessionsProvider(context.Background(), query)
}
func (s *FakeUserAuthTokenService) UpdateExternalSession(ctx context.Context, externalSessionID int64, cmd *auth.UpdateExternalSessionCommand) error {
return s.UpdateExternalSessionProvider(context.Background(), externalSessionID, cmd)
}
func (s *FakeUserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*auth.UserToken, error) {
return s.LookupTokenProvider(context.Background(), unhashedToken)
}
@@ -149,16 +156,6 @@ func (ts *FakeOAuthTokenService) IsOAuthPassThruEnabled(*datasources.DataSource)
return ts.passThruEnabled
}
func (ts *FakeOAuthTokenService) HasOAuthEntry(context.Context, identity.Requester) (*login.UserAuth, bool, error) {
if ts.ExpectedAuthUser != nil {
return ts.ExpectedAuthUser, true, nil
}
if error, ok := ts.ExpectedErrors["HasOAuthEntry"]; ok {
return nil, false, error
}
return nil, false, nil
}
func (ts *FakeOAuthTokenService) InvalidateOAuthTokens(ctx context.Context, usr *login.UserAuth) error {
ts.ExpectedAuthUser.OAuthAccessToken = ""
ts.ExpectedAuthUser.OAuthRefreshToken = ""
+8
View File
@@ -3,6 +3,8 @@ package auth
import (
"context"
"time"
"golang.org/x/oauth2"
)
type ExternalSession struct {
@@ -43,6 +45,10 @@ func (e *ExternalSession) Clone() *ExternalSession {
}
}
type UpdateExternalSessionCommand struct {
Token *oauth2.Token
}
type ListExternalSessionQuery struct {
ID int64
NameID string
@@ -57,6 +63,8 @@ type ExternalSessionStore interface {
List(ctx context.Context, query *ListExternalSessionQuery) ([]*ExternalSession, error)
// Create creates a new external session for a user
Create(ctx context.Context, extSesion *ExternalSession) error
// Update updates an external session
Update(ctx context.Context, ID int64, cmd *UpdateExternalSessionCommand) error
// Delete deletes an external session
Delete(ctx context.Context, ID int64) error
// DeleteExternalSessionsByUserID deletes an external session
+1 -1
View File
@@ -178,7 +178,7 @@ type RedirectClient interface {
// that should happen during logout and supports client specific redirect URL.
type LogoutClient interface {
Client
Logout(ctx context.Context, user identity.Requester) (*Redirect, bool)
Logout(ctx context.Context, user identity.Requester, sessionToken *usertoken.UserToken) (*Redirect, bool)
}
type SSOSettingsAwareClient interface {
+3 -3
View File
@@ -37,7 +37,7 @@ func ProvideRegistration(
jwtService auth.JWTVerifierService, userProtectionService login.UserProtectionService,
loginAttempts loginattempt.Service, quotaService quota.Service,
authInfoService login.AuthInfoService, renderService rendering.Service,
features *featuremgmt.FeatureManager, oauthTokenService oauthtoken.OAuthTokenService,
features featuremgmt.FeatureToggles, oauthTokenService oauthtoken.OAuthTokenService,
socialService social.Service, cache *remotecache.RemoteCache,
ldapService service.LDAP, settingsProviderService setting.Provider,
tracer tracing.Tracer, tempUserService tempuser.Service, notificationService notifications.Service,
@@ -108,13 +108,13 @@ func ProvideRegistration(
}
// FIXME (jguer): move to User package
userSync := sync.ProvideUserSync(userService, userProtectionService, authInfoService, quotaService, tracer)
userSync := sync.ProvideUserSync(userService, userProtectionService, authInfoService, quotaService, tracer, features)
orgSync := sync.ProvideOrgSync(userService, orgService, accessControlService, cfg, tracer)
authnSvc.RegisterPostAuthHook(userSync.SyncUserHook, 10)
authnSvc.RegisterPostAuthHook(userSync.EnableUserHook, 20)
authnSvc.RegisterPostAuthHook(orgSync.SyncOrgRolesHook, 30)
authnSvc.RegisterPostAuthHook(userSync.SyncLastSeenHook, 130)
authnSvc.RegisterPostAuthHook(sync.ProvideOAuthTokenSync(oauthTokenService, sessionService, socialService, tracer).SyncOauthTokenHook, 60)
authnSvc.RegisterPostAuthHook(sync.ProvideOAuthTokenSync(oauthTokenService, sessionService, socialService, tracer, features).SyncOauthTokenHook, 60)
authnSvc.RegisterPostAuthHook(userSync.FetchSyncedUserHook, 100)
rbacSync := sync.ProvideRBACSync(accessControlService, tracer, permRegistry)
+1 -1
View File
@@ -322,7 +322,7 @@ func (s *Service) Logout(ctx context.Context, user identity.Requester, sessionTo
goto Default
}
clientRedirect, ok := logoutClient.Logout(ctx, user)
clientRedirect, ok := logoutClient.Logout(ctx, user, sessionToken)
if !ok {
goto Default
}
+1 -1
View File
@@ -507,7 +507,7 @@ func TestService_Logout(t *testing.T) {
expectedRedirect: &authn.Redirect{URL: "http://idp.com/logout"},
client: &authntest.MockClient{
NameFunc: func() string { return "auth.client.azuread" },
LogoutFunc: func(ctx context.Context, _ identity.Requester) (*authn.Redirect, bool) {
LogoutFunc: func(ctx context.Context, _ identity.Requester, sessionToken *usertoken.UserToken) (*authn.Redirect, bool) {
return &authn.Redirect{URL: "http://idp.com/logout"}, true
},
},
@@ -17,12 +17,15 @@ import (
"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/featuremgmt"
"github.com/grafana/grafana/pkg/services/oauthtoken"
)
const maxOAuthTokenCacheTTL = 5 * time.Minute
func ProvideOAuthTokenSync(service oauthtoken.OAuthTokenService, sessionService auth.UserTokenService, socialService social.Service, tracer tracing.Tracer) *OAuthTokenSync {
func ProvideOAuthTokenSync(service oauthtoken.OAuthTokenService, sessionService auth.UserTokenService, socialService social.Service, tracer tracing.Tracer,
features featuremgmt.FeatureToggles,
) *OAuthTokenSync {
return &OAuthTokenSync{
log.New("oauth_token.sync"),
service,
@@ -31,6 +34,7 @@ func ProvideOAuthTokenSync(service oauthtoken.OAuthTokenService, sessionService
new(singleflight.Group),
tracer,
localcache.New(maxOAuthTokenCacheTTL, 15*time.Minute),
features,
}
}
@@ -42,6 +46,7 @@ type OAuthTokenSync struct {
singleflightGroup *singleflight.Group
tracer tracing.Tracer
cache *localcache.CacheService
features featuremgmt.FeatureToggles
}
func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, id *authn.Identity, _ *authn.Request) error {
@@ -72,6 +77,10 @@ func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, id *authn.Ident
ctxLogger := s.log.FromContext(ctx).New("userID", userID)
cacheKey := fmt.Sprintf("token-check-%s", id.GetID())
if s.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) {
cacheKey = fmt.Sprintf("token-check-%s-%d", id.GetID(), id.SessionToken.Id)
}
if _, ok := s.cache.Get(cacheKey); ok {
ctxLogger.Debug("Expiration check has been cached, no need to refresh")
return nil
@@ -83,7 +92,7 @@ func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, id *authn.Ident
updateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
token, refreshErr := s.service.TryTokenRefresh(updateCtx, id)
token, refreshErr := s.service.TryTokenRefresh(updateCtx, id, id.SessionToken)
if refreshErr != nil {
if errors.Is(refreshErr, context.Canceled) {
return nil, nil
@@ -20,6 +20,9 @@ import (
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/authtest"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/oauthtoken/oauthtokentest"
)
@@ -85,7 +88,7 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) {
)
service := &oauthtokentest.MockOauthTokenService{
TryTokenRefreshFunc: func(ctx context.Context, usr identity.Requester) (*oauth2.Token, error) {
TryTokenRefreshFunc: func(ctx context.Context, usr identity.Requester, _ *auth.UserToken) (*oauth2.Token, error) {
tryRefreshCalled = true
return nil, tt.expectedTryRefreshErr
},
@@ -116,9 +119,13 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) {
singleflightGroup: new(singleflight.Group),
tracer: tracing.InitializeTracerForTest(),
cache: localcache.New(maxOAuthTokenCacheTTL, 15*time.Minute),
features: featuremgmt.WithFeatures(),
}
err := sync.SyncOauthTokenHook(context.Background(), tt.identity, nil)
ctx := context.Background()
reqCtx := context.WithValue(ctx, ctxkey.Key{}, &contextmodel.ReqContext{UserToken: nil})
err := sync.SyncOauthTokenHook(reqCtx, tt.identity, nil)
assert.ErrorIs(t, err, tt.expectedErr)
assert.Equal(t, tt.expectTryRefreshTokenCalled, tryRefreshCalled)
assert.Equal(t, tt.expectRevokeTokenCalled, revokeTokenCalled)
+22 -8
View File
@@ -12,6 +12,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/quota"
@@ -51,7 +52,9 @@ var (
errSignupNotAllowed = errors.New("system administrator has disabled signup")
)
func ProvideUserSync(userService user.Service, userProtectionService login.UserProtectionService, authInfoService login.AuthInfoService, quotaService quota.Service, tracer tracing.Tracer) *UserSync {
func ProvideUserSync(userService user.Service, userProtectionService login.UserProtectionService, authInfoService login.AuthInfoService,
quotaService quota.Service, tracer tracing.Tracer, features featuremgmt.FeatureToggles,
) *UserSync {
return &UserSync{
userService: userService,
authInfoService: authInfoService,
@@ -59,6 +62,7 @@ func ProvideUserSync(userService user.Service, userProtectionService login.UserP
quotaService: quotaService,
log: log.New("user.sync"),
tracer: tracer,
features: features,
}
}
@@ -69,6 +73,7 @@ type UserSync struct {
quotaService quota.Service
log log.Logger
tracer tracing.Tracer
features featuremgmt.FeatureToggles
}
// SyncUserHook syncs a user with the database
@@ -223,21 +228,30 @@ func (s *UserSync) upsertAuthConnection(ctx context.Context, userID int64, ident
// This can happen when: using multiple auth client where the same user exists in several or
// changing to new auth client
if createConnection {
return s.authInfoService.SetAuthInfo(ctx, &login.SetAuthInfoCommand{
setAuthInfoCmd := &login.SetAuthInfoCommand{
UserId: userID,
AuthModule: identity.AuthenticatedBy,
AuthId: identity.AuthID,
OAuthToken: identity.OAuthToken,
})
}
if !s.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) {
setAuthInfoCmd.OAuthToken = identity.OAuthToken
}
return s.authInfoService.SetAuthInfo(ctx, setAuthInfoCmd)
}
s.log.FromContext(ctx).Debug("Updating auth connection for user", "id", identity.ID)
return s.authInfoService.UpdateAuthInfo(ctx, &login.UpdateAuthInfoCommand{
updateAuthInfoCmd := &login.UpdateAuthInfoCommand{
UserId: userID,
AuthId: identity.AuthID,
AuthModule: identity.AuthenticatedBy,
OAuthToken: identity.OAuthToken,
})
}
if !s.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) {
updateAuthInfoCmd.OAuthToken = identity.OAuthToken
}
s.log.FromContext(ctx).Debug("Updating auth connection for user", "id", identity.ID)
return s.authInfoService.UpdateAuthInfo(ctx, updateAuthInfoCmd)
}
func (s *UserSync) updateUserAttributes(ctx context.Context, usr *user.User, id *authn.Identity, userAuth *login.UserAuth) error {
@@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/login/authinfoimpl"
"github.com/grafana/grafana/pkg/services/login/authinfotest"
@@ -45,7 +46,9 @@ func TestUserSync_SyncUserHook(t *testing.T) {
AuthModule: "oauth",
AuthId: "2032",
UserId: 1,
Id: 1}}
Id: 1,
},
}
userService := &usertest.FakeUserService{ExpectedUser: &user.User{
ID: 1,
@@ -434,7 +437,7 @@ func TestUserSync_SyncUserHook(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := ProvideUserSync(tt.fields.userService, userProtection, tt.fields.authInfoService, tt.fields.quotaService, tracing.InitializeTracerForTest())
s := ProvideUserSync(tt.fields.userService, userProtection, tt.fields.authInfoService, tt.fields.quotaService, tracing.InitializeTracerForTest(), featuremgmt.WithFeatures())
err := s.SyncUserHook(tt.args.ctx, tt.args.id, nil)
if tt.wantErr {
require.Error(t, err)
+3 -3
View File
@@ -85,7 +85,7 @@ type MockClient struct {
TestFunc func(ctx context.Context, r *authn.Request) bool
PriorityFunc func() uint
HookFunc func(ctx context.Context, identity *authn.Identity, r *authn.Request) error
LogoutFunc func(ctx context.Context, user identity.Requester) (*authn.Redirect, bool)
LogoutFunc func(ctx context.Context, user identity.Requester, sessionToken *usertoken.UserToken) (*authn.Redirect, bool)
IdentityTypeFunc func() claims.IdentityType
ResolveIdentityFunc func(ctx context.Context, orgID int64, typ claims.IdentityType, id string) (*authn.Identity, error)
}
@@ -133,9 +133,9 @@ func (m MockClient) Hook(ctx context.Context, identity *authn.Identity, r *authn
return nil
}
func (m *MockClient) Logout(ctx context.Context, user identity.Requester) (*authn.Redirect, bool) {
func (m *MockClient) Logout(ctx context.Context, user identity.Requester, sessionToken *usertoken.UserToken) (*authn.Redirect, bool) {
if m.LogoutFunc != nil {
return m.LogoutFunc(ctx, user)
return m.LogoutFunc(ctx, user, sessionToken)
}
return nil, false
}
+4 -3
View File
@@ -18,6 +18,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/login/social/connectors"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/login"
@@ -257,8 +258,8 @@ func (c *OAuth) RedirectURL(ctx context.Context, r *authn.Request) (*authn.Redir
}, nil
}
func (c *OAuth) Logout(ctx context.Context, user identity.Requester) (*authn.Redirect, bool) {
token := c.oauthService.GetCurrentOAuthToken(ctx, user)
func (c *OAuth) Logout(ctx context.Context, user identity.Requester, sessionToken *auth.UserToken) (*authn.Redirect, bool) {
token := c.oauthService.GetCurrentOAuthToken(ctx, user, sessionToken)
userID, err := identity.UserIdentifier(user.GetID())
if err != nil {
@@ -268,7 +269,7 @@ func (c *OAuth) Logout(ctx context.Context, user identity.Requester) (*authn.Red
ctxLogger := c.log.FromContext(ctx).New("userID", userID)
if err := c.oauthService.InvalidateOAuthTokens(ctx, user); err != nil {
if err := c.oauthService.InvalidateOAuthTokens(ctx, user, sessionToken); err != nil {
ctxLogger.Error("Failed to invalidate tokens", "error", err)
}
+4 -3
View File
@@ -18,6 +18,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/login/social/socialtest"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/login"
@@ -469,7 +470,7 @@ func TestOAuth_Logout(t *testing.T) {
)
mockService := &oauthtokentest.MockOauthTokenService{
GetCurrentOauthTokenFunc: func(_ context.Context, _ identity.Requester) *oauth2.Token {
GetCurrentOauthTokenFunc: func(_ context.Context, _ identity.Requester, _ *auth.UserToken) *oauth2.Token {
getTokenCalled = true
token := &oauth2.Token{
AccessToken: "some.access.token",
@@ -479,7 +480,7 @@ func TestOAuth_Logout(t *testing.T) {
"id_token": "some.id.token",
})
},
InvalidateOAuthTokensFunc: func(_ context.Context, _ identity.Requester) error {
InvalidateOAuthTokensFunc: func(_ context.Context, _ identity.Requester, _ *auth.UserToken) error {
invalidateTokenCalled = true
return nil
},
@@ -490,7 +491,7 @@ func TestOAuth_Logout(t *testing.T) {
}
c := ProvideOAuth(authn.ClientWithPrefix("azuread"), tt.cfg, mockService, fakeSocialSvc, &setting.OSSImpl{Cfg: tt.cfg}, featuremgmt.WithFeatures())
redirect, ok := c.Logout(context.Background(), &authn.Identity{ID: "1", Type: claims.TypeUser})
redirect, ok := c.Logout(context.Background(), &authn.Identity{ID: "1", Type: claims.TypeUser}, nil)
assert.Equal(t, tt.expectedOK, ok)
if tt.expectedOK {
+4 -4
View File
@@ -92,7 +92,6 @@ func (s *Store) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuer
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
return sess.Table("user_auth").In("user_id", params).OrderBy("created").Find(&userAuths)
})
if err != nil {
return nil, err
}
@@ -187,7 +186,9 @@ func (s *Store) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCom
}
return s.sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
upd, err := sess.MustCols("o_auth_expiry").Where("user_id = ? AND auth_module = ?", cmd.UserId, cmd.AuthModule).Update(authUser)
upd, err := sess.MustCols("o_auth_expiry", "o_auth_access_token", "o_auth_refresh_token", "o_auth_id_token", "o_auth_token_type").
Where("user_id = ? AND auth_module = ?", cmd.UserId, cmd.AuthModule).
Update(authUser)
s.logger.Debug("Updated user_auth", "user_id", cmd.UserId, "auth_id", cmd.AuthId, "auth_module", cmd.AuthModule, "rows", upd)
@@ -198,7 +199,6 @@ func (s *Store) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCom
"SELECT id FROM user_auth WHERE user_id = ? AND auth_module = ? AND auth_id = ?",
cmd.UserId, cmd.AuthModule, cmd.AuthId,
).Get(&id)
if err != nil {
return err
}
@@ -220,7 +220,7 @@ func (s *Store) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCom
func (s *Store) DeleteUserAuthInfo(ctx context.Context, userID int64) error {
return s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
var rawSQL = "DELETE FROM user_auth WHERE user_id = ?"
rawSQL := "DELETE FROM user_auth WHERE user_id = ?"
_, err := sess.Exec(rawSQL, userID)
return err
})
+159 -64
View File
@@ -19,7 +19,9 @@ import (
"github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
@@ -39,28 +41,33 @@ type Service struct {
Cfg *setting.Cfg
SocialService social.Service
AuthInfoService login.AuthInfoService
sessionService auth.UserTokenService
features featuremgmt.FeatureToggles
serverLock *serverlock.ServerLockService
tracer tracing.Tracer
tokenRefreshDuration *prometheus.HistogramVec
}
var _ OAuthTokenService = (*Service)(nil)
//go:generate mockery --name OAuthTokenService --structname MockService --outpkg oauthtokentest --filename service_mock.go --output ./oauthtokentest/
type OAuthTokenService interface {
GetCurrentOAuthToken(context.Context, identity.Requester) *oauth2.Token
GetCurrentOAuthToken(context.Context, identity.Requester, *auth.UserToken) *oauth2.Token
IsOAuthPassThruEnabled(*datasources.DataSource) bool
HasOAuthEntry(context.Context, identity.Requester) (*login.UserAuth, bool, error)
TryTokenRefresh(context.Context, identity.Requester) (*oauth2.Token, error)
InvalidateOAuthTokens(context.Context, identity.Requester) error
TryTokenRefresh(context.Context, identity.Requester, *auth.UserToken) (*oauth2.Token, error)
InvalidateOAuthTokens(context.Context, identity.Requester, *auth.UserToken) error
}
func ProvideService(socialService social.Service, authInfoService login.AuthInfoService, cfg *setting.Cfg, registerer prometheus.Registerer,
serverLockService *serverlock.ServerLockService, tracer tracing.Tracer,
serverLockService *serverlock.ServerLockService, tracer tracing.Tracer, sessionService auth.UserTokenService, features featuremgmt.FeatureToggles,
) *Service {
return &Service{
AuthInfoService: authInfoService,
sessionService: sessionService,
Cfg: cfg,
SocialService: socialService,
features: features,
serverLock: serverLockService,
tokenRefreshDuration: newTokenRefreshDurationMetric(registerer),
tracer: tracer,
@@ -68,7 +75,7 @@ func ProvideService(socialService social.Service, authInfoService login.AuthInfo
}
// GetCurrentOAuthToken returns the OAuth token, if any, for the authenticated user. Will try to refresh the token if it has expired.
func (o *Service) GetCurrentOAuthToken(ctx context.Context, usr identity.Requester) *oauth2.Token {
func (o *Service) GetCurrentOAuthToken(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) *oauth2.Token {
ctx, span := o.tracer.Start(ctx, "oauthtoken.GetCurrentOAuthToken")
defer span.End()
@@ -93,30 +100,54 @@ func (o *Service) GetCurrentOAuthToken(ctx context.Context, usr identity.Request
ctxLogger = ctxLogger.New("userID", userID)
authInfo, ok, _ := o.HasOAuthEntry(ctx, usr)
if !ok {
if !strings.HasPrefix(usr.GetAuthenticatedBy(), "oauth_") {
ctxLogger.Warn("The specified user's auth provider is not oauth",
"authmodule", usr.GetAuthenticatedBy())
return nil
}
if err := checkOAuthRefreshToken(authInfo); err != nil {
if errors.Is(err, ErrNoRefreshTokenFound) {
return buildOAuthTokenFromAuthInfo(authInfo)
var persistedToken *oauth2.Token
if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) {
externalSession, err := o.sessionService.GetExternalSession(ctx, sessionToken.ExternalSessionId)
if err != nil {
if errors.Is(err, auth.ErrExternalSessionNotFound) {
return nil
}
ctxLogger.Error("Failed to fetch external session", "error", err)
return nil
}
return nil
}
persistedToken = buildOAuthTokenFromExternalSession(externalSession)
persistedToken := buildOAuthTokenFromAuthInfo(authInfo)
if persistedToken.RefreshToken == "" {
return persistedToken
}
} else {
authInfo, ok, _ := o.hasOAuthEntry(ctx, usr)
if !ok {
return nil
}
if err := checkOAuthRefreshToken(authInfo); err != nil {
if errors.Is(err, ErrNoRefreshTokenFound) {
return buildOAuthTokenFromAuthInfo(authInfo)
}
return nil
}
persistedToken = buildOAuthTokenFromAuthInfo(authInfo)
}
refreshNeeded := needTokenRefresh(ctx, persistedToken)
if !refreshNeeded {
return persistedToken
}
token, err := o.TryTokenRefresh(ctx, usr)
token, err := o.TryTokenRefresh(ctx, usr, sessionToken)
if err != nil {
if errors.Is(err, ErrNoRefreshTokenFound) {
return buildOAuthTokenFromAuthInfo(authInfo)
return persistedToken
}
return nil
@@ -130,9 +161,9 @@ func (o *Service) IsOAuthPassThruEnabled(ds *datasources.DataSource) bool {
return IsOAuthPassThruEnabled(ds)
}
// HasOAuthEntry returns true and the UserAuth object when OAuth info exists for the specified User
func (o *Service) HasOAuthEntry(ctx context.Context, usr identity.Requester) (*login.UserAuth, bool, error) {
ctx, span := o.tracer.Start(ctx, "oauthtoken.HasOAuthEntry")
// hasOAuthEntry returns true and the UserAuth object when OAuth info exists for the specified User
func (o *Service) hasOAuthEntry(ctx context.Context, usr identity.Requester) (*login.UserAuth, bool, error) {
ctx, span := o.tracer.Start(ctx, "oauthtoken.hasOAuthEntry")
defer span.End()
if usr == nil || usr.IsNil() {
@@ -167,12 +198,19 @@ func (o *Service) HasOAuthEntry(ctx context.Context, usr identity.Requester) (*l
if !strings.Contains(authInfo.AuthModule, "oauth") {
return nil, false, nil
}
// An extra check to ensure that the user has an OAuth token
// It's required to handle the case when the `improvedExternalSessionHandling` feature flag gets disabled
if authInfo.OAuthAccessToken == "" {
ctxLogger.Debug("No access token found for user")
return nil, false, fmt.Errorf("no access token found for user %d", userID)
}
return authInfo, true, nil
}
// TryTokenRefresh returns an error in case the OAuth token refresh was unsuccessful
// It uses a server lock to prevent getting the Refresh Token multiple times for a given User
func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester) (*oauth2.Token, error) {
func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) (*oauth2.Token, error) {
ctx, span := o.tracer.Start(ctx, "oauthtoken.TryTokenRefresh")
defer span.End()
@@ -218,6 +256,9 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester) (
}
lockKey := fmt.Sprintf("oauth-refresh-token-%d", userID)
if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) {
lockKey = fmt.Sprintf("oauth-refresh-token-%d-%d", userID, sessionToken.ExternalSessionId)
}
lockTimeConfig := serverlock.LockTimeConfig{
MaxInterval: 30 * time.Second,
@@ -242,15 +283,32 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester) (
ctxLogger.Debug("Serverlock request for getting a new access token", "key", lockKey)
authInfo, exists, err := o.HasOAuthEntry(ctx, usr)
if !exists {
var persistedToken *oauth2.Token
var externalSession *auth.ExternalSession
if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) {
externalSession, err = o.sessionService.GetExternalSession(ctx, sessionToken.ExternalSessionId)
if err != nil {
ctxLogger.Debug("Failed to fetch oauth entry", "error", err)
if errors.Is(err, auth.ErrExternalSessionNotFound) {
ctxLogger.Error("External session was not found for user", "error", err)
return
}
ctxLogger.Error("Failed to fetch external session", "error", err)
return
}
return
}
persistedToken := buildOAuthTokenFromAuthInfo(authInfo)
persistedToken = buildOAuthTokenFromExternalSession(externalSession)
} else {
authInfo, exists, err := o.hasOAuthEntry(ctx, usr)
if !exists {
if err != nil {
ctxLogger.Debug("Failed to fetch oauth entry", "error", err)
cmdErr = err
}
return
}
persistedToken = buildOAuthTokenFromAuthInfo(authInfo)
}
needRefresh := needTokenRefresh(ctx, persistedToken)
if !needRefresh {
@@ -259,7 +317,7 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester) (
return
}
newToken, cmdErr = o.tryGetOrRefreshOAuthToken(ctx, persistedToken, usr)
newToken, cmdErr = o.tryGetOrRefreshOAuthToken(ctx, persistedToken, usr, sessionToken)
}, retryOpt)
if lockErr != nil {
ctxLogger.Error("Failed to obtain token refresh lock", "error", lockErr)
@@ -274,45 +332,27 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester) (
return newToken, cmdErr
}
func buildOAuthTokenFromAuthInfo(authInfo *login.UserAuth) *oauth2.Token {
token := &oauth2.Token{
AccessToken: authInfo.OAuthAccessToken,
Expiry: authInfo.OAuthExpiry,
RefreshToken: authInfo.OAuthRefreshToken,
TokenType: authInfo.OAuthTokenType,
}
if authInfo.OAuthIdToken != "" {
token = token.WithExtra(map[string]any{"id_token": authInfo.OAuthIdToken})
}
return token
}
func checkOAuthRefreshToken(authInfo *login.UserAuth) error {
if !strings.Contains(authInfo.AuthModule, "oauth") {
logger.Warn("The specified user's auth provider is not oauth",
"authmodule", authInfo.AuthModule, "userid", authInfo.UserId)
return ErrNotAnOAuthProvider
}
if authInfo.OAuthRefreshToken == "" {
logger.Warn("No refresh token available",
"authmodule", authInfo.AuthModule, "userid", authInfo.UserId)
return ErrNoRefreshTokenFound
}
return nil
}
// InvalidateOAuthTokens invalidates the OAuth tokens (access_token, refresh_token) and sets the Expiry to default/zero
func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr identity.Requester) error {
func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) error {
userID, err := usr.GetInternalID()
if err != nil {
logger.Error("Failed to convert user id to int", "id", usr.GetID(), "error", err)
return err
}
ctxLogger := logger.FromContext(ctx).New("userID", userID)
if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) {
err := o.sessionService.UpdateExternalSession(ctx, sessionToken.ExternalSessionId, &auth.UpdateExternalSessionCommand{
Token: &oauth2.Token{},
})
if err != nil {
ctxLogger.Error("Failed to update external session", "error", err)
return err
}
}
// TODO: Should this run regardless of the feature flag?
return o.AuthInfoService.UpdateAuthInfo(ctx, &login.UpdateAuthInfoCommand{
UserId: userID,
AuthModule: usr.GetAuthenticatedBy(),
@@ -325,7 +365,7 @@ func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr identity.Reques
})
}
func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken *oauth2.Token, usr identity.Requester) (*oauth2.Token, error) {
func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken *oauth2.Token, usr identity.Requester, sessionToken *auth.UserToken) (*oauth2.Token, error) {
ctx, span := o.tracer.Start(ctx, "oauthtoken.tryGetOrRefreshOAuthToken")
defer span.End()
@@ -374,7 +414,7 @@ func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken
"provider", usr.GetAuthenticatedBy(), "error", err)
// token refresh failed, invalidate the old token
if err := o.InvalidateOAuthTokens(ctx, usr); err != nil {
if err := o.InvalidateOAuthTokens(ctx, usr, sessionToken); err != nil {
ctxLogger.Warn("Failed to invalidate OAuth tokens", "authID", usr.GetAuthID(), "error", err)
}
@@ -399,10 +439,20 @@ func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken
)
}
if err := o.AuthInfoService.UpdateAuthInfo(ctx, updateAuthCommand); err != nil {
ctxLogger.Error("Failed to update auth info during token refresh", "authID", usr.GetAuthID(), "error", err)
return token, err
if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) {
if err := o.sessionService.UpdateExternalSession(ctx, sessionToken.ExternalSessionId, &auth.UpdateExternalSessionCommand{
Token: token,
}); err != nil {
ctxLogger.Error("Failed to update external session during token refresh", "error", err)
return token, err
}
} else {
if err := o.AuthInfoService.UpdateAuthInfo(ctx, updateAuthCommand); err != nil {
ctxLogger.Error("Failed to update auth info during token refresh", "authID", usr.GetAuthID(), "error", err)
return token, err
}
}
ctxLogger.Debug("Updated oauth info for user")
}
@@ -467,6 +517,51 @@ func needTokenRefresh(ctx context.Context, persistedToken *oauth2.Token) bool {
return true
}
func buildOAuthTokenFromAuthInfo(authInfo *login.UserAuth) *oauth2.Token {
token := &oauth2.Token{
AccessToken: authInfo.OAuthAccessToken,
Expiry: authInfo.OAuthExpiry,
RefreshToken: authInfo.OAuthRefreshToken,
TokenType: authInfo.OAuthTokenType,
}
if authInfo.OAuthIdToken != "" {
token = token.WithExtra(map[string]any{"id_token": authInfo.OAuthIdToken})
}
return token
}
func buildOAuthTokenFromExternalSession(externalSession *auth.ExternalSession) *oauth2.Token {
token := &oauth2.Token{
AccessToken: externalSession.AccessToken,
Expiry: externalSession.ExpiresAt,
RefreshToken: externalSession.RefreshToken,
}
if externalSession.IDToken != "" {
token = token.WithExtra(map[string]any{"id_token": externalSession.IDToken})
}
return token
}
func checkOAuthRefreshToken(authInfo *login.UserAuth) error {
if !strings.Contains(authInfo.AuthModule, "oauth") {
logger.Warn("The specified user's auth provider is not oauth",
"authmodule", authInfo.AuthModule, "userid", authInfo.UserId)
return ErrNotAnOAuthProvider
}
if authInfo.OAuthRefreshToken == "" {
logger.Warn("No refresh token available",
"authmodule", authInfo.AuthModule, "userid", authInfo.UserId)
return ErrNoRefreshTokenFound
}
return nil
}
// GetIDTokenExpiry extracts the expiry time from the ID token
func GetIDTokenExpiry(token *oauth2.Token) (time.Time, error) {
idToken, ok := token.Extra("id_token").(string)
+280 -110
View File
@@ -3,32 +3,29 @@ package oauthtoken
import (
"context"
"errors"
"reflect"
"testing"
"time"
"github.com/grafana/authlib/claims"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/login/social/socialtest"
"github.com/grafana/grafana/pkg/models/usertoken"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/authtest"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/login/authinfoimpl"
"github.com/grafana/grafana/pkg/services/login/authinfotest"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"golang.org/x/oauth2"
)
const EXPIRED_ID_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiMTIzNDU2Nzg5MCIsImF1ZCI6InlvdXItY2xpZW50LWlkIiwiZXhwIjoxNjAwMDAwMDAwLCJpYXQiOjE2MDAwMDAwMDAsIm5hbWUiOiJKb2huIERvZSIsImVtYWlsIjoiam9obkBleGFtcGxlLmNvbSJ9.c2lnbmF0dXJl" // #nosec G101 not a hardcoded credential
@@ -39,104 +36,6 @@ func TestMain(m *testing.M) {
testsuite.Run(m)
}
func TestService_HasOAuthEntry(t *testing.T) {
testCases := []struct {
name string
user *user.SignedInUser
want *login.UserAuth
wantExist bool
wantErr bool
err error
getAuthInfoErr error
getAuthInfoUser login.UserAuth
}{
{
name: "returns false without an error in case user is nil",
user: nil,
want: nil,
wantExist: false,
wantErr: false,
},
{
name: "returns false and an error in case GetAuthInfo returns an error",
user: &user.SignedInUser{UserID: 1},
want: nil,
wantExist: false,
wantErr: true,
getAuthInfoErr: errors.New("error"),
},
{
name: "returns false without an error in case auth entry is not found",
user: &user.SignedInUser{UserID: 1},
want: nil,
wantExist: false,
wantErr: false,
getAuthInfoErr: user.ErrUserNotFound,
},
{
name: "returns false without an error in case the auth entry is not oauth",
user: &user.SignedInUser{UserID: 1},
want: nil,
wantExist: false,
wantErr: false,
getAuthInfoUser: login.UserAuth{AuthModule: "auth_saml"},
},
{
name: "returns true when the auth entry is found",
user: &user.SignedInUser{UserID: 1},
want: &login.UserAuth{AuthModule: login.GenericOAuthModule},
wantExist: true,
wantErr: false,
getAuthInfoUser: login.UserAuth{AuthModule: login.GenericOAuthModule},
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
srv, authInfoStore, _ := setupOAuthTokenService(t)
authInfoStore.ExpectedOAuth = &tc.getAuthInfoUser
authInfoStore.ExpectedError = tc.getAuthInfoErr
entry, exists, err := srv.HasOAuthEntry(context.Background(), tc.user)
if tc.wantErr {
assert.Error(t, err)
}
if tc.want != nil {
assert.True(t, reflect.DeepEqual(tc.want, entry))
}
assert.Equal(t, tc.wantExist, exists)
})
}
}
func setupOAuthTokenService(t *testing.T) (*Service, *FakeAuthInfoStore, *socialtest.MockSocialConnector) {
t.Helper()
socialConnector := &socialtest.MockSocialConnector{}
socialService := &socialtest.FakeSocialService{
ExpectedConnector: socialConnector,
ExpectedAuthInfoProvider: &social.OAuthInfo{
UseRefreshToken: true,
},
}
authInfoStore := &FakeAuthInfoStore{ExpectedOAuth: &login.UserAuth{}}
authInfoService := authinfoimpl.ProvideService(authInfoStore, remotecache.NewFakeCacheStorage(), secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()))
store := db.InitTestDB(t)
return &Service{
Cfg: setting.NewCfg(),
SocialService: socialService,
AuthInfoService: authInfoService,
serverLock: serverlock.ProvideService(store, tracing.InitializeTracerForTest()),
tokenRefreshDuration: newTokenRefreshDurationMetric(prometheus.NewRegistry()),
tracer: tracing.InitializeTracerForTest(),
}, authInfoStore, socialConnector
}
type FakeAuthInfoStore struct {
login.Store
ExpectedError error
@@ -379,10 +278,12 @@ func TestService_TryTokenRefresh(t *testing.T) {
prometheus.NewRegistry(),
env.serverLock,
tracing.InitializeTracerForTest(),
nil,
featuremgmt.WithFeatures(),
)
// token refresh
actualToken, err := env.service.TryTokenRefresh(context.Background(), tt.identity)
actualToken, err := env.service.TryTokenRefresh(context.Background(), tt.identity, nil)
if tt.expectedErr != nil {
assert.ErrorIs(t, err, tt.expectedErr)
@@ -407,6 +308,275 @@ func TestService_TryTokenRefresh(t *testing.T) {
}
}
func TestService_TryTokenRefresh_WithExternalSessions(t *testing.T) {
unexpiredToken := &oauth2.Token{
AccessToken: "testaccess",
RefreshToken: "testrefresh",
Expiry: time.Now().Add(time.Hour),
TokenType: "Bearer",
}
unexpiredTokenWithIDToken := unexpiredToken.WithExtra(map[string]interface{}{
"id_token": UNEXPIRED_ID_TOKEN,
})
expiredToken := &oauth2.Token{
AccessToken: "testaccess",
RefreshToken: "testrefresh",
Expiry: time.Now().Add(-time.Hour),
TokenType: "Bearer",
}
userIdentity := &authn.Identity{
AuthenticatedBy: login.GenericOAuthModule,
ID: "1234",
Type: claims.TypeUser,
}
type environment struct {
sessionService *authtest.MockUserAuthTokenService
serverLock *serverlock.ServerLockService
socialConnector *socialtest.MockSocialConnector
socialService *socialtest.FakeSocialService
service *Service
}
type testCase struct {
desc string
identity identity.Requester
setup func(env *environment)
expectedToken *oauth2.Token
expectedErr error
}
tests := []testCase{
{
desc: "should skip sync when identity is nil",
},
{
desc: "should skip sync when identity is not a user",
identity: &authn.Identity{ID: "1", Type: claims.TypeServiceAccount},
},
{
desc: "should skip token refresh and return nil if namespace and id cannot be converted to user ID",
identity: &authn.Identity{ID: "invalid", Type: claims.TypeUser},
},
{
desc: "should skip token refresh if there's an unexpected error while looking up the user oauth entry, additionally, no error should be returned",
identity: userIdentity,
setup: func(env *environment) {
env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(nil, assert.AnError).Once()
env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{
UseRefreshToken: true,
}
},
},
// Kinda impossible to happen, can only happen after the feature is enabled and logged in users don't have their external sessions set
{
desc: "should skip token refresh if the user doesn't have an external session",
identity: userIdentity,
setup: func(env *environment) {
env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(nil, auth.ErrExternalSessionNotFound).Once()
env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{
UseRefreshToken: true,
}
},
},
{
desc: "should skip token refresh when no oauth provider was found",
identity: userIdentity,
setup: func(env *environment) {
env.socialService.ExpectedAuthInfoProvider = nil
},
},
{
desc: "should skip token refresh when oauth provider token handling is disabled (UseRefreshToken is false)",
identity: userIdentity,
setup: func(env *environment) {
env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{
UseRefreshToken: false,
}
},
},
{
desc: "should skip token refresh when the token is still valid and no id token is present",
identity: userIdentity,
setup: func(env *environment) {
env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{
ID: 1,
UserID: 1,
AccessToken: unexpiredTokenWithIDToken.AccessToken,
RefreshToken: unexpiredTokenWithIDToken.RefreshToken,
ExpiresAt: unexpiredTokenWithIDToken.Expiry,
}, nil).Once()
env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{
UseRefreshToken: true,
}
},
expectedToken: unexpiredToken,
},
{
desc: "should not do token refresh if access token or id token have not expired yet",
identity: userIdentity,
setup: func(env *environment) {
env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{
ID: 1,
UserID: 1,
AccessToken: unexpiredTokenWithIDToken.AccessToken,
RefreshToken: unexpiredTokenWithIDToken.RefreshToken,
ExpiresAt: unexpiredTokenWithIDToken.Expiry,
IDToken: UNEXPIRED_ID_TOKEN,
}, nil).Once()
env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{
UseRefreshToken: true,
}
},
expectedToken: unexpiredTokenWithIDToken,
},
{
desc: "should skip token refresh when there is no refresh token",
identity: userIdentity,
setup: func(env *environment) {
env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{
ID: 1,
UserID: 1,
AccessToken: unexpiredTokenWithIDToken.AccessToken,
RefreshToken: "",
ExpiresAt: unexpiredTokenWithIDToken.Expiry,
}, nil).Once()
env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{
UseRefreshToken: true,
}
},
expectedToken: &oauth2.Token{
AccessToken: unexpiredTokenWithIDToken.AccessToken,
RefreshToken: "",
Expiry: unexpiredTokenWithIDToken.Expiry,
},
},
{
desc: "should refresh token when the access token is expired",
identity: &authn.Identity{
AuthenticatedBy: login.GenericOAuthModule,
ID: "1",
Type: claims.TypeUser,
},
setup: func(env *environment) {
env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{
ID: 1,
UserID: 1,
AccessToken: expiredToken.AccessToken,
IDToken: UNEXPIRED_ID_TOKEN,
RefreshToken: expiredToken.RefreshToken,
ExpiresAt: expiredToken.Expiry,
}, nil).Once()
env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once()
env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once()
env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{
UseRefreshToken: true,
}
},
expectedToken: unexpiredTokenWithIDToken,
},
{
desc: "should refresh token when the id token is expired",
identity: userIdentity,
setup: func(env *environment) {
env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{
ID: 1,
UserID: 1,
AccessToken: unexpiredTokenWithIDToken.AccessToken,
RefreshToken: unexpiredTokenWithIDToken.RefreshToken,
ExpiresAt: unexpiredTokenWithIDToken.Expiry,
IDToken: EXPIRED_ID_TOKEN,
}, nil).Once()
env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{
UseRefreshToken: true,
}
env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once()
env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once()
},
expectedToken: unexpiredTokenWithIDToken,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
socialConnector := socialtest.NewMockSocialConnector(t)
store := db.InitTestDB(t)
env := environment{
sessionService: authtest.NewMockUserAuthTokenService(t),
serverLock: serverlock.ProvideService(store, tracing.InitializeTracerForTest()),
socialConnector: socialConnector,
socialService: &socialtest.FakeSocialService{
ExpectedConnector: socialConnector,
},
}
if tt.setup != nil {
tt.setup(&env)
}
env.service = ProvideService(
env.socialService,
nil,
setting.NewCfg(),
prometheus.NewRegistry(),
env.serverLock,
tracing.InitializeTracerForTest(),
env.sessionService,
featuremgmt.WithFeatures(featuremgmt.FlagImprovedExternalSessionHandling),
)
// token refresh
actualToken, err := env.service.TryTokenRefresh(context.Background(), tt.identity, &usertoken.UserToken{ExternalSessionId: 1})
if tt.expectedErr != nil {
assert.ErrorIs(t, err, tt.expectedErr)
return
}
assert.NoError(t, err)
if tt.expectedToken == nil {
assert.Nil(t, actualToken)
return
}
assert.Equal(t, tt.expectedToken.AccessToken, actualToken.AccessToken)
assert.Equal(t, tt.expectedToken.RefreshToken, actualToken.RefreshToken)
assert.Equal(t, tt.expectedToken.Expiry, actualToken.Expiry)
if tt.expectedToken.Extra("id_token") != nil {
assert.Equal(t, tt.expectedToken.Extra("id_token").(string), actualToken.Extra("id_token").(string))
} else {
assert.Nil(t, actualToken.Extra("id_token"))
}
})
}
}
func verifyUpdateExternalSessionCommand(token *oauth2.Token) func(*auth.UpdateExternalSessionCommand) bool {
return func(cmd *auth.UpdateExternalSessionCommand) bool {
idToken := cmd.Token.Extra("id_token")
return cmd.Token.AccessToken == token.AccessToken &&
cmd.Token.RefreshToken == token.RefreshToken &&
cmd.Token.Expiry == token.Expiry &&
idToken == token.Extra("id_token")
}
}
func TestOAuthTokenSync_needTokenRefresh(t *testing.T) {
tests := []struct {
name string
+10 -18
View File
@@ -6,21 +6,20 @@ import (
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/login"
)
type MockOauthTokenService struct {
GetCurrentOauthTokenFunc func(ctx context.Context, usr identity.Requester) *oauth2.Token
GetCurrentOauthTokenFunc func(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) *oauth2.Token
IsOAuthPassThruEnabledFunc func(ds *datasources.DataSource) bool
HasOAuthEntryFunc func(ctx context.Context, usr identity.Requester) (*login.UserAuth, bool, error)
InvalidateOAuthTokensFunc func(ctx context.Context, usr identity.Requester) error
TryTokenRefreshFunc func(ctx context.Context, usr identity.Requester) (*oauth2.Token, error)
InvalidateOAuthTokensFunc func(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) error
TryTokenRefreshFunc func(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) (*oauth2.Token, error)
}
func (m *MockOauthTokenService) GetCurrentOAuthToken(ctx context.Context, usr identity.Requester) *oauth2.Token {
func (m *MockOauthTokenService) GetCurrentOAuthToken(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) *oauth2.Token {
if m.GetCurrentOauthTokenFunc != nil {
return m.GetCurrentOauthTokenFunc(ctx, usr)
return m.GetCurrentOauthTokenFunc(ctx, usr, sessionToken)
}
return nil
}
@@ -32,23 +31,16 @@ func (m *MockOauthTokenService) IsOAuthPassThruEnabled(ds *datasources.DataSourc
return false
}
func (m *MockOauthTokenService) HasOAuthEntry(ctx context.Context, usr identity.Requester) (*login.UserAuth, bool, error) {
if m.HasOAuthEntryFunc != nil {
return m.HasOAuthEntryFunc(ctx, usr)
}
return nil, false, nil
}
func (m *MockOauthTokenService) InvalidateOAuthTokens(ctx context.Context, usr identity.Requester) error {
func (m *MockOauthTokenService) InvalidateOAuthTokens(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) error {
if m.InvalidateOAuthTokensFunc != nil {
return m.InvalidateOAuthTokensFunc(ctx, usr)
return m.InvalidateOAuthTokensFunc(ctx, usr, sessionToken)
}
return nil
}
func (m *MockOauthTokenService) TryTokenRefresh(ctx context.Context, usr identity.Requester) (*oauth2.Token, error) {
func (m *MockOauthTokenService) TryTokenRefresh(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) (*oauth2.Token, error) {
if m.TryTokenRefreshFunc != nil {
return m.TryTokenRefreshFunc(ctx, usr)
return m.TryTokenRefreshFunc(ctx, usr, sessionToken)
}
return nil, nil
}
@@ -6,8 +6,8 @@ import (
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/oauthtoken"
)
@@ -21,7 +21,7 @@ func ProvideService() *Service {
return &Service{}
}
func (s *Service) GetCurrentOAuthToken(context.Context, identity.Requester) *oauth2.Token {
func (s *Service) GetCurrentOAuthToken(context.Context, identity.Requester, *auth.UserToken) *oauth2.Token {
return s.Token
}
@@ -29,14 +29,10 @@ func (s *Service) IsOAuthPassThruEnabled(ds *datasources.DataSource) bool {
return oauthtoken.IsOAuthPassThruEnabled(ds)
}
func (s *Service) HasOAuthEntry(context.Context, identity.Requester) (*login.UserAuth, bool, error) {
return nil, false, nil
}
func (s *Service) TryTokenRefresh(context.Context, identity.Requester) (*oauth2.Token, error) {
func (s *Service) TryTokenRefresh(context.Context, identity.Requester, *auth.UserToken) (*oauth2.Token, error) {
return s.Token, nil
}
func (s *Service) InvalidateOAuthTokens(context.Context, identity.Requester) error {
func (s *Service) InvalidateOAuthTokens(context.Context, identity.Requester, *auth.UserToken) error {
return nil
}
@@ -1,4 +1,4 @@
// Code generated by mockery v2.40.1. DO NOT EDIT.
// Code generated by mockery v2.42.1. DO NOT EDIT.
package oauthtokentest
@@ -8,11 +8,11 @@ import (
identity "github.com/grafana/grafana/pkg/apimachinery/identity"
datasources "github.com/grafana/grafana/pkg/services/datasources"
login "github.com/grafana/grafana/pkg/services/login"
mock "github.com/stretchr/testify/mock"
oauth2 "golang.org/x/oauth2"
usertoken "github.com/grafana/grafana/pkg/models/usertoken"
)
// MockService is an autogenerated mock type for the OAuthTokenService type
@@ -20,17 +20,17 @@ type MockService struct {
mock.Mock
}
// GetCurrentOAuthToken provides a mock function with given fields: _a0, _a1
func (_m *MockService) GetCurrentOAuthToken(_a0 context.Context, _a1 identity.Requester) *oauth2.Token {
ret := _m.Called(_a0, _a1)
// GetCurrentOAuthToken provides a mock function with given fields: _a0, _a1, _a2
func (_m *MockService) GetCurrentOAuthToken(_a0 context.Context, _a1 identity.Requester, _a2 *usertoken.UserToken) *oauth2.Token {
ret := _m.Called(_a0, _a1, _a2)
if len(ret) == 0 {
panic("no return value specified for GetCurrentOAuthToken")
}
var r0 *oauth2.Token
if rf, ok := ret.Get(0).(func(context.Context, identity.Requester) *oauth2.Token); ok {
r0 = rf(_a0, _a1)
if rf, ok := ret.Get(0).(func(context.Context, identity.Requester, *usertoken.UserToken) *oauth2.Token); ok {
r0 = rf(_a0, _a1, _a2)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*oauth2.Token)
@@ -40,54 +40,17 @@ func (_m *MockService) GetCurrentOAuthToken(_a0 context.Context, _a1 identity.Re
return r0
}
// HasOAuthEntry provides a mock function with given fields: _a0, _a1
func (_m *MockService) HasOAuthEntry(_a0 context.Context, _a1 identity.Requester) (*login.UserAuth, bool, error) {
ret := _m.Called(_a0, _a1)
if len(ret) == 0 {
panic("no return value specified for HasOAuthEntry")
}
var r0 *login.UserAuth
var r1 bool
var r2 error
if rf, ok := ret.Get(0).(func(context.Context, identity.Requester) (*login.UserAuth, bool, error)); ok {
return rf(_a0, _a1)
}
if rf, ok := ret.Get(0).(func(context.Context, identity.Requester) *login.UserAuth); ok {
r0 = rf(_a0, _a1)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*login.UserAuth)
}
}
if rf, ok := ret.Get(1).(func(context.Context, identity.Requester) bool); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Get(1).(bool)
}
if rf, ok := ret.Get(2).(func(context.Context, identity.Requester) error); ok {
r2 = rf(_a0, _a1)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// InvalidateOAuthTokens provides a mock function with given fields: _a0, _a1
func (_m *MockService) InvalidateOAuthTokens(_a0 context.Context, _a1 *login.UserAuth) error {
ret := _m.Called(_a0, _a1)
// InvalidateOAuthTokens provides a mock function with given fields: _a0, _a1, _a2
func (_m *MockService) InvalidateOAuthTokens(_a0 context.Context, _a1 identity.Requester, _a2 *usertoken.UserToken) error {
ret := _m.Called(_a0, _a1, _a2)
if len(ret) == 0 {
panic("no return value specified for InvalidateOAuthTokens")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *login.UserAuth) error); ok {
r0 = rf(_a0, _a1)
if rf, ok := ret.Get(0).(func(context.Context, identity.Requester, *usertoken.UserToken) error); ok {
r0 = rf(_a0, _a1, _a2)
} else {
r0 = ret.Error(0)
}
@@ -113,22 +76,34 @@ func (_m *MockService) IsOAuthPassThruEnabled(_a0 *datasources.DataSource) bool
return r0
}
// TryTokenRefresh provides a mock function with given fields: _a0, _a1
func (_m *MockService) TryTokenRefresh(_a0 context.Context, _a1 identity.Requester) error {
ret := _m.Called(_a0, _a1)
// TryTokenRefresh provides a mock function with given fields: _a0, _a1, _a2
func (_m *MockService) TryTokenRefresh(_a0 context.Context, _a1 identity.Requester, _a2 *usertoken.UserToken) (*oauth2.Token, error) {
ret := _m.Called(_a0, _a1, _a2)
if len(ret) == 0 {
panic("no return value specified for TryTokenRefresh")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, identity.Requester) error); ok {
r0 = rf(_a0, _a1)
var r0 *oauth2.Token
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, identity.Requester, *usertoken.UserToken) (*oauth2.Token, error)); ok {
return rf(_a0, _a1, _a2)
}
if rf, ok := ret.Get(0).(func(context.Context, identity.Requester, *usertoken.UserToken) *oauth2.Token); ok {
r0 = rf(_a0, _a1, _a2)
} else {
r0 = ret.Error(0)
if ret.Get(0) != nil {
r0 = ret.Get(0).(*oauth2.Token)
}
}
return r0
if rf, ok := ret.Get(1).(func(context.Context, identity.Requester, *usertoken.UserToken) error); ok {
r1 = rf(_a0, _a1, _a2)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
@@ -50,7 +50,7 @@ func (m *OAuthTokenMiddleware) applyToken(ctx context.Context, pCtx backend.Plug
}
if m.oAuthTokenService.IsOAuthPassThruEnabled(ds) {
if token := m.oAuthTokenService.GetCurrentOAuthToken(ctx, reqCtx.SignedInUser); token != nil {
if token := m.oAuthTokenService.GetCurrentOAuthToken(ctx, reqCtx.SignedInUser, reqCtx.UserToken); token != nil {
authorizationHeader := fmt.Sprintf("%s %s", token.Type(), token.AccessToken)
idTokenHeader := ""