mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
OAuth: Introduce user_refresh_token setting and make it default for the selected providers (#71533)
* First changes * WIP docs * Align current tests * Add test for UseRefreshToken * Update docs * Fix * Remove unnecessary AuthCodeURL from generic_oauth * Change GitHub to disable use_refresh_token by default
This commit is contained in:
parent
1f3aa099d5
commit
dcf26564db
@ -600,6 +600,8 @@ tls_skip_verify_insecure = false
|
||||
tls_client_cert =
|
||||
tls_client_key =
|
||||
tls_client_ca =
|
||||
# GitHub OAuth apps does not provide refresh tokens and the access tokens never expires.
|
||||
use_refresh_token = false
|
||||
|
||||
#################################### GitLab Auth #########################
|
||||
[auth.gitlab]
|
||||
@ -625,6 +627,7 @@ tls_client_cert =
|
||||
tls_client_key =
|
||||
tls_client_ca =
|
||||
use_pkce = true
|
||||
use_refresh_token = true
|
||||
|
||||
#################################### Google Auth #########################
|
||||
[auth.google]
|
||||
@ -647,6 +650,7 @@ tls_client_cert =
|
||||
tls_client_key =
|
||||
tls_client_ca =
|
||||
use_pkce = true
|
||||
use_refresh_token = true
|
||||
|
||||
#################################### Grafana.com Auth ####################
|
||||
# legacy key names (so they work in env variables)
|
||||
@ -657,6 +661,7 @@ client_id = some_id
|
||||
client_secret =
|
||||
scopes = user:email
|
||||
allowed_organizations =
|
||||
use_refresh_token = false
|
||||
|
||||
[auth.grafana_com]
|
||||
name = Grafana.com
|
||||
@ -669,6 +674,7 @@ client_secret =
|
||||
scopes = user:email
|
||||
allowed_organizations =
|
||||
skip_org_role_sync = false
|
||||
use_refresh_token = false
|
||||
|
||||
#################################### Azure AD OAuth #######################
|
||||
[auth.azuread]
|
||||
@ -694,6 +700,7 @@ tls_client_key =
|
||||
tls_client_ca =
|
||||
use_pkce = true
|
||||
skip_org_role_sync = false
|
||||
use_refresh_token = true
|
||||
|
||||
#################################### Okta OAuth #######################
|
||||
[auth.okta]
|
||||
@ -719,6 +726,7 @@ tls_client_cert =
|
||||
tls_client_key =
|
||||
tls_client_ca =
|
||||
use_pkce = true
|
||||
use_refresh_token = false
|
||||
|
||||
#################################### Generic OAuth #######################
|
||||
[auth.generic_oauth]
|
||||
@ -756,6 +764,7 @@ use_pkce = false
|
||||
auth_style =
|
||||
allow_assign_grafana_admin = false
|
||||
skip_org_role_sync = false
|
||||
use_refresh_token = false
|
||||
|
||||
#################################### Basic Auth ##########################
|
||||
[auth.basic]
|
||||
|
@ -21,6 +21,7 @@ The Azure AD authentication allows you to use an Azure Active Directory tenant a
|
||||
- [Assign server administrator privileges](#assign-server-administrator-privileges)
|
||||
- [Enable Azure AD OAuth in Grafana](#enable-azure-ad-oauth-in-grafana)
|
||||
- [Configure refresh token](#configure-refresh-token)
|
||||
- [Configure allowed tenants](#configure-allowed-tenants)
|
||||
- [Configure allowed groups](#configure-allowed-groups)
|
||||
- [Configure allowed domains](#configure-allowed-domains)
|
||||
- [PKCE](#pkce)
|
||||
@ -176,7 +177,9 @@ When a user logs in using an OAuth provider, Grafana verifies that the access to
|
||||
|
||||
Grafana uses a refresh token to obtain a new access token without requiring the user to log in again. If a refresh token doesn't exist, Grafana logs the user out of the system after the access token has expired.
|
||||
|
||||
To enable a refresh token for AzureAD, extend the `scopes` in `[auth.azuread]` with `offline_access`.
|
||||
Refresh token fetching and access token expiration check is enabled by default for the AzureAD provider since Grafana v10.1.0 if the `accessTokenExpirationCheck` feature toggle is enabled. If you would like to disable access token expiration check then set the `use_refresh_token` configuration value to `false`.
|
||||
|
||||
> **Note:** The `accessTokenExpirationCheck` feature toggle will be removed in Grafana v10.2.0 and the `use_refresh_token` configuration value will be used instead for configuring refresh token fetching and access token expiration check.
|
||||
|
||||
### Configure allowed tenants
|
||||
|
||||
|
@ -65,7 +65,9 @@ To integrate your OAuth2 provider with Grafana using our generic OAuth2 authenti
|
||||
|
||||
b. Extend the `scopes` field of `[auth.generic_oauth]` section in Grafana configuration file with refresh token scope used by your OAuth2 provider.
|
||||
|
||||
c. Enable the refresh token on the provider if required.
|
||||
c. Set `use_refresh_token` to `true` in `[auth.generic_oauth]` section in Grafana configuration file.
|
||||
|
||||
d. Enable the refresh token on the provider if required.
|
||||
|
||||
1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}).
|
||||
1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization" >}}).
|
||||
@ -113,6 +115,7 @@ The following table outlines the various generic OAuth2 configuration options. Y
|
||||
| `tls_client_key` | No | The path to the key. | |
|
||||
| `tls_client_ca` | No | The path to the trusted certificate authority list. | |
|
||||
| `use_pkce` | No | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `false` |
|
||||
| `use_refresh_token` | No | Set to `true` to use refresh token and check access token expiration. The `accessTokenExpirationCheck` feature toggle should also be enabled to use refresh token. | `false` |
|
||||
|
||||
### Configure login
|
||||
|
||||
@ -169,11 +172,13 @@ When a user logs in using an OAuth2 provider, Grafana verifies that the access t
|
||||
|
||||
Grafana uses a refresh token to obtain a new access token without requiring the user to log in again. If a refresh token doesn't exist, Grafana logs the user out of the system after the access token has expired.
|
||||
|
||||
To configure generic OAuth2 to use a refresh token, perform one or both of the following steps, if required:
|
||||
To configure generic OAuth2 to use a refresh token, set `use_refresh_token` configuration option to `true` and perform one or both of the following steps, if required:
|
||||
|
||||
1. Extend the `scopes` field of `[auth.generic_oauth]` section in Grafana configuration file with additional scopes.
|
||||
1. Enable the refresh token on the provider.
|
||||
|
||||
> **Note:** The `accessTokenExpirationCheck` feature toggle will be removed in Grafana v10.2.0 and the `use_refresh_token` configuration value will be used instead for configuring refresh token fetching and access token expiration check.
|
||||
|
||||
## Configure role mapping
|
||||
|
||||
Unless `skip_org_role_sync` option is enabled, the user's role will be set to the role retrieved from the auth provider upon user login.
|
||||
@ -336,6 +341,7 @@ To set up generic OAuth2 authentication with Auth0, follow these steps:
|
||||
token_url = https://<domain>/oauth/token
|
||||
api_url = https://<domain>/userinfo
|
||||
use_pkce = true
|
||||
use_refresh_token = true
|
||||
```
|
||||
|
||||
### Set up OAuth2 with Bitbucket
|
||||
@ -368,6 +374,7 @@ To set up generic OAuth2 authentication with Bitbucket, follow these steps:
|
||||
team_ids_attribute_path = values[*].workspace.slug
|
||||
team_ids =
|
||||
allowed_organizations =
|
||||
use_refresh_token = true
|
||||
```
|
||||
|
||||
By default, a refresh token is included in the response for the **Authorization Code Grant**.
|
||||
|
@ -104,6 +104,10 @@ Grafana uses a refresh token to obtain a new access token without requiring the
|
||||
|
||||
By default, GitLab provides a refresh token.
|
||||
|
||||
Refresh token fetching and access token expiration check is enabled by default for the GitLab provider since Grafana v10.1.0 if the `accessTokenExpirationCheck` feature toggle is enabled. If you would like to disable access token expiration check then set the `use_refresh_token` configuration value to `false`.
|
||||
|
||||
> **Note:** The `accessTokenExpirationCheck` feature toggle will be removed in Grafana v10.2.0 and the `use_refresh_token` configuration value will be used instead for configuring refresh token fetching and access token expiration check.
|
||||
|
||||
### allowed_groups
|
||||
|
||||
To limit access to authenticated users that are members of one or more [GitLab
|
||||
|
@ -82,6 +82,10 @@ Grafana uses a refresh token to obtain a new access token without requiring the
|
||||
|
||||
By default, Grafana includes the `access_type=offline` parameter in the authorization request to request a refresh token.
|
||||
|
||||
Refresh token fetching and access token expiration check is enabled by default for the Google provider since Grafana v10.1.0 if the `accessTokenExpirationCheck` feature toggle is enabled. If you would like to disable access token expiration check then set the `use_refresh_token` configuration value to `false`.
|
||||
|
||||
> **Note:** The `accessTokenExpirationCheck` feature toggle will be removed in Grafana v10.2.0 and the `use_refresh_token` configuration value will be used instead for configuring refresh token fetching and access token expiration check.
|
||||
|
||||
### Configure automatic login
|
||||
|
||||
Set `auto_login` option to true to attempt login automatically, skipping the login screen.
|
||||
|
@ -75,7 +75,10 @@ When a user logs in using an OAuth provider, Grafana verifies that the access to
|
||||
Grafana uses a refresh token to obtain a new access token without requiring the user to log in again. If a refresh token doesn't exist, Grafana logs the user out of the system after the access token has expired.
|
||||
|
||||
1. To enable the `Refresh Token`, grant type in the `General Settings` section.
|
||||
1. Extend the `scopes` in `[auth.okta]` with `offline_access`.
|
||||
1. Extend the `scopes` in `[auth.okta]` with `offline_access` for Grafana versions between v9.3 and v10.0.x.
|
||||
1. Set `use_refresh_token` in `[auth.okta]` to `true` for Grafana versions v10.1.0 and later.
|
||||
|
||||
> **Note:** The `accessTokenExpirationCheck` feature toggle will be removed in Grafana v10.2.0 and the `use_refresh_token` configuration value will be used instead for configuring refresh token fetching and access token expiration check.
|
||||
|
||||
### Configure allowed groups and domains
|
||||
|
||||
|
@ -11,8 +11,6 @@ import (
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
)
|
||||
|
||||
type SocialGenericOAuth struct {
|
||||
@ -489,13 +487,6 @@ func (s *SocialGenericOAuth) FetchOrganizations(ctx context.Context, client *htt
|
||||
return logins, true
|
||||
}
|
||||
|
||||
func (s *SocialGenericOAuth) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
|
||||
if s.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
|
||||
opts = append(opts, oauth2.AccessTypeOffline)
|
||||
}
|
||||
return s.SocialBase.AuthCodeURL(state, opts...)
|
||||
}
|
||||
|
||||
func (s *SocialGenericOAuth) SupportBundleContent(bf *bytes.Buffer) error {
|
||||
bf.WriteString("## GenericOAuth specific configuration\n\n")
|
||||
bf.WriteString("```ini\n")
|
||||
|
@ -114,7 +114,7 @@ func (s *SocialGoogle) extractFromAPI(ctx context.Context, client *http.Client)
|
||||
}
|
||||
|
||||
func (s *SocialGoogle) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
|
||||
if s.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
|
||||
if s.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) && s.useRefreshToken {
|
||||
opts = append(opts, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
|
||||
}
|
||||
return s.SocialBase.AuthCodeURL(state, opts...)
|
||||
|
@ -17,6 +17,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
@ -31,6 +32,10 @@ import (
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
const (
|
||||
OfflineAccessScope = "offline_access"
|
||||
)
|
||||
|
||||
type SocialService struct {
|
||||
cfg *setting.Cfg
|
||||
|
||||
@ -66,6 +71,7 @@ type OAuthInfo struct {
|
||||
RoleAttributeStrict bool `toml:"role_attribute_strict"`
|
||||
TlsSkipVerify bool `toml:"tls_skip_verify"`
|
||||
UsePKCE bool `toml:"use_pkce"`
|
||||
UseRefreshToken bool `toml:"use_refresh_token"`
|
||||
}
|
||||
|
||||
func ProvideService(cfg *setting.Cfg,
|
||||
@ -111,6 +117,7 @@ func ProvideService(cfg *setting.Cfg,
|
||||
TlsClientCa: sec.Key("tls_client_ca").String(),
|
||||
TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(),
|
||||
UsePKCE: sec.Key("use_pkce").MustBool(),
|
||||
UseRefreshToken: sec.Key("use_refresh_token").MustBool(false),
|
||||
AllowAssignGrafanaAdmin: sec.Key("allow_assign_grafana_admin").MustBool(false),
|
||||
AutoLogin: sec.Key("auto_login").MustBool(false),
|
||||
}
|
||||
@ -198,6 +205,9 @@ func ProvideService(cfg *setting.Cfg,
|
||||
forceUseGraphAPI: sec.Key("force_use_graph_api").MustBool(false),
|
||||
skipOrgRoleSync: cfg.AzureADSkipOrgRoleSync,
|
||||
}
|
||||
if info.UseRefreshToken && features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
|
||||
appendUniqueScope(&config, OfflineAccessScope)
|
||||
}
|
||||
}
|
||||
|
||||
// Okta
|
||||
@ -208,6 +218,9 @@ func ProvideService(cfg *setting.Cfg,
|
||||
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
|
||||
skipOrgRoleSync: cfg.OktaSkipOrgRoleSync,
|
||||
}
|
||||
if info.UseRefreshToken && features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
|
||||
appendUniqueScope(&config, OfflineAccessScope)
|
||||
}
|
||||
}
|
||||
|
||||
// Generic - Uses the same scheme as GitHub.
|
||||
@ -271,6 +284,7 @@ func (b *BasicUserInfo) String() string {
|
||||
b.Id, b.Name, b.Email, b.Login, b.Role, b.Groups)
|
||||
}
|
||||
|
||||
//go:generate mockery --name SocialConnector --structname MockSocialConnector --outpkg socialtest --filename social_connector_mock.go --output ../socialtest/
|
||||
type SocialConnector interface {
|
||||
UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*BasicUserInfo, error)
|
||||
IsEmailAllowed(email string) bool
|
||||
@ -295,6 +309,7 @@ type SocialBase struct {
|
||||
autoAssignOrgRole string
|
||||
skipOrgRoleSync bool
|
||||
features featuremgmt.FeatureManager
|
||||
useRefreshToken bool
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
@ -344,6 +359,7 @@ func newSocialBase(name string,
|
||||
roleAttributeStrict: info.RoleAttributeStrict,
|
||||
skipOrgRoleSync: skipOrgRoleSync,
|
||||
features: features,
|
||||
useRefreshToken: info.UseRefreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
@ -592,3 +608,9 @@ func (s *SocialBase) retrieveRawIDToken(idToken interface{}) ([]byte, error) {
|
||||
|
||||
return rawJSON, nil
|
||||
}
|
||||
|
||||
func appendUniqueScope(config *oauth2.Config, scope string) {
|
||||
if !slices.Contains(config.Scopes, OfflineAccessScope) {
|
||||
config.Scopes = append(config.Scopes, OfflineAccessScope)
|
||||
}
|
||||
}
|
||||
|
190
pkg/login/socialtest/social_connector_mock.go
Normal file
190
pkg/login/socialtest/social_connector_mock.go
Normal file
@ -0,0 +1,190 @@
|
||||
// Code generated by mockery v2.27.1. DO NOT EDIT.
|
||||
|
||||
package socialtest
|
||||
|
||||
import (
|
||||
bytes "bytes"
|
||||
context "context"
|
||||
|
||||
http "net/http"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
oauth2 "golang.org/x/oauth2"
|
||||
|
||||
social "github.com/grafana/grafana/pkg/login/social"
|
||||
)
|
||||
|
||||
// MockSocialConnector is an autogenerated mock type for the SocialConnector type
|
||||
type MockSocialConnector struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// AuthCodeURL provides a mock function with given fields: state, opts
|
||||
func (_m *MockSocialConnector) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
|
||||
_va := make([]interface{}, len(opts))
|
||||
for _i := range opts {
|
||||
_va[_i] = opts[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, state)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 string
|
||||
if rf, ok := ret.Get(0).(func(string, ...oauth2.AuthCodeOption) string); ok {
|
||||
r0 = rf(state, opts...)
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Client provides a mock function with given fields: ctx, t
|
||||
func (_m *MockSocialConnector) Client(ctx context.Context, t *oauth2.Token) *http.Client {
|
||||
ret := _m.Called(ctx, t)
|
||||
|
||||
var r0 *http.Client
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *oauth2.Token) *http.Client); ok {
|
||||
r0 = rf(ctx, t)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*http.Client)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Exchange provides a mock function with given fields: ctx, code, authOptions
|
||||
func (_m *MockSocialConnector) Exchange(ctx context.Context, code string, authOptions ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||
_va := make([]interface{}, len(authOptions))
|
||||
for _i := range authOptions {
|
||||
_va[_i] = authOptions[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, ctx, code)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 *oauth2.Token
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error)); ok {
|
||||
return rf(ctx, code, authOptions...)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, ...oauth2.AuthCodeOption) *oauth2.Token); ok {
|
||||
r0 = rf(ctx, code, authOptions...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*oauth2.Token)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string, ...oauth2.AuthCodeOption) error); ok {
|
||||
r1 = rf(ctx, code, authOptions...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// IsEmailAllowed provides a mock function with given fields: email
|
||||
func (_m *MockSocialConnector) IsEmailAllowed(email string) bool {
|
||||
ret := _m.Called(email)
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(string) bool); ok {
|
||||
r0 = rf(email)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// IsSignupAllowed provides a mock function with given fields:
|
||||
func (_m *MockSocialConnector) IsSignupAllowed() bool {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func() bool); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// SupportBundleContent provides a mock function with given fields: _a0
|
||||
func (_m *MockSocialConnector) SupportBundleContent(_a0 *bytes.Buffer) error {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(*bytes.Buffer) error); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// TokenSource provides a mock function with given fields: ctx, t
|
||||
func (_m *MockSocialConnector) TokenSource(ctx context.Context, t *oauth2.Token) oauth2.TokenSource {
|
||||
ret := _m.Called(ctx, t)
|
||||
|
||||
var r0 oauth2.TokenSource
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *oauth2.Token) oauth2.TokenSource); ok {
|
||||
r0 = rf(ctx, t)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(oauth2.TokenSource)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// UserInfo provides a mock function with given fields: ctx, client, token
|
||||
func (_m *MockSocialConnector) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*social.BasicUserInfo, error) {
|
||||
ret := _m.Called(ctx, client, token)
|
||||
|
||||
var r0 *social.BasicUserInfo
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *http.Client, *oauth2.Token) (*social.BasicUserInfo, error)); ok {
|
||||
return rf(ctx, client, token)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *http.Client, *oauth2.Token) *social.BasicUserInfo); ok {
|
||||
r0 = rf(ctx, client, token)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*social.BasicUserInfo)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *http.Client, *oauth2.Token) error); ok {
|
||||
r1 = rf(ctx, client, token)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
type mockConstructorTestingTNewMockSocialConnector interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}
|
||||
|
||||
// NewMockSocialConnector creates a new instance of MockSocialConnector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
func NewMockSocialConnector(t mockConstructorTestingTNewMockSocialConnector) *MockSocialConnector {
|
||||
mock := &MockSocialConnector{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
33
pkg/login/socialtest/social_service_fake.go
Normal file
33
pkg/login/socialtest/social_service_fake.go
Normal file
@ -0,0 +1,33 @@
|
||||
package socialtest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana/pkg/login/social"
|
||||
)
|
||||
|
||||
type FakeSocialService struct {
|
||||
ExpectedAuthInfoProvider *social.OAuthInfo
|
||||
ExpectedConnector social.SocialConnector
|
||||
ExpectedHttpClient *http.Client
|
||||
}
|
||||
|
||||
func (fss *FakeSocialService) GetOAuthProviders() map[string]bool {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (fss *FakeSocialService) GetOAuthHttpClient(string) (*http.Client, error) {
|
||||
return fss.ExpectedHttpClient, nil
|
||||
}
|
||||
|
||||
func (fss *FakeSocialService) GetConnector(string) (social.SocialConnector, error) {
|
||||
return fss.ExpectedConnector, nil
|
||||
}
|
||||
|
||||
func (fss *FakeSocialService) GetOAuthInfoProvider(string) *social.OAuthInfo {
|
||||
return fss.ExpectedAuthInfoProvider
|
||||
}
|
||||
|
||||
func (fss *FakeSocialService) GetOAuthInfoProviders() map[string]*social.OAuthInfo {
|
||||
panic("not implemented")
|
||||
}
|
@ -158,7 +158,7 @@ func ProvideService(
|
||||
s.RegisterPostAuthHook(userSyncService.SyncLastSeenHook, 40)
|
||||
|
||||
if features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
|
||||
s.RegisterPostAuthHook(sync.ProvideOAuthTokenSync(oauthTokenService, sessionService).SyncOauthTokenHook, 60)
|
||||
s.RegisterPostAuthHook(sync.ProvideOAuthTokenSync(oauthTokenService, sessionService, socialService).SyncOauthTokenHook, 60)
|
||||
}
|
||||
|
||||
s.RegisterPostAuthHook(userSyncService.FetchSyncedUserHook, 100)
|
||||
|
@ -3,10 +3,12 @@ package sync
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/oauthtoken"
|
||||
@ -15,15 +17,19 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errExpiredAccessToken = errutil.NewBase(errutil.StatusUnauthorized, "oauth.expired-token")
|
||||
errExpiredAccessToken = errutil.NewBase(
|
||||
errutil.StatusUnauthorized,
|
||||
"oauth.expired-token",
|
||||
errutil.WithPublicMessage("OAuth access token expired"))
|
||||
)
|
||||
|
||||
func ProvideOAuthTokenSync(service oauthtoken.OAuthTokenService, sessionService auth.UserTokenService) *OAuthTokenSync {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,6 +38,7 @@ type OAuthTokenSync struct {
|
||||
cache *localcache.CacheService
|
||||
service oauthtoken.OAuthTokenService
|
||||
sessionService auth.UserTokenService
|
||||
socialService social.Service
|
||||
}
|
||||
|
||||
func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, identity *authn.Identity, _ *authn.Request) error {
|
||||
@ -64,6 +71,19 @@ func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, identity *authn
|
||||
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
|
||||
}
|
||||
|
||||
expires := token.OAuthExpiry.Round(0).Add(-oauthtoken.ExpiryDelta)
|
||||
// token has not expired, so we don't have to refresh it
|
||||
if !expires.Before(time.Now()) {
|
||||
@ -78,14 +98,14 @@ func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, identity *authn
|
||||
}
|
||||
|
||||
if err := s.service.InvalidateOAuthTokens(ctx, token); err != nil {
|
||||
s.log.FromContext(ctx).Error("Failed invalidate OAuth tokens", "id", identity.ID, "error", err)
|
||||
s.log.FromContext(ctx).Error("Failed to invalidate OAuth tokens", "id", identity.ID, "error", err)
|
||||
}
|
||||
|
||||
if err := s.sessionService.RevokeToken(ctx, identity.SessionToken, false); err != nil {
|
||||
s.log.FromContext(ctx).Error("Failed to revoke session token", "id", identity.ID, "tokenId", identity.SessionToken.Id, "error", err)
|
||||
}
|
||||
|
||||
return errExpiredAccessToken.Errorf("oauth access token could not be refreshed: %w", auth.ErrInvalidSessionToken)
|
||||
return errExpiredAccessToken.Errorf("oauth access token could not be refreshed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -10,6 +10,8 @@ import (
|
||||
|
||||
"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/socialtest"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/auth/authtest"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
@ -20,8 +22,9 @@ import (
|
||||
|
||||
func TestOauthTokenSync_SyncOAuthTokenHook(t *testing.T) {
|
||||
type testCase struct {
|
||||
desc string
|
||||
identity *authn.Identity
|
||||
desc string
|
||||
identity *authn.Identity
|
||||
oauthInfo *social.OAuthInfo
|
||||
|
||||
expectedHasEntryToken *login.UserAuth
|
||||
expectHasEntryCalled bool
|
||||
@ -84,6 +87,13 @@ func TestOauthTokenSync_SyncOAuthTokenHook(t *testing.T) {
|
||||
expectRevokeTokenCalled: true,
|
||||
expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(-10 * time.Minute)},
|
||||
expectedErr: 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},
|
||||
},
|
||||
}
|
||||
|
||||
@ -118,11 +128,22 @@ func TestOauthTokenSync_SyncOAuthTokenHook(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if tt.oauthInfo == nil {
|
||||
tt.oauthInfo = &social.OAuthInfo{
|
||||
UseRefreshToken: true,
|
||||
}
|
||||
}
|
||||
|
||||
socialService := &socialtest.FakeSocialService{
|
||||
ExpectedAuthInfoProvider: tt.oauthInfo,
|
||||
}
|
||||
|
||||
sync := &OAuthTokenSync{
|
||||
log: log.NewNopLogger(),
|
||||
cache: localcache.New(0, 0),
|
||||
service: service,
|
||||
sessionService: sessionService,
|
||||
socialService: socialService,
|
||||
}
|
||||
|
||||
err := sync.SyncOauthTokenHook(context.Background(), tt.identity, nil)
|
||||
|
@ -1,10 +1,8 @@
|
||||
package oauthtoken
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
@ -15,7 +13,7 @@ import (
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||
"github.com/grafana/grafana/pkg/login/social"
|
||||
"github.com/grafana/grafana/pkg/login/socialtest"
|
||||
"github.com/grafana/grafana/pkg/services/login"
|
||||
"github.com/grafana/grafana/pkg/services/login/authinfoservice"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
@ -221,12 +219,12 @@ func TestService_TryTokenRefresh_DifferentAuthModuleForUser(t *testing.T) {
|
||||
socialConnector.AssertNotCalled(t, "TokenSource")
|
||||
}
|
||||
|
||||
func setupOAuthTokenService(t *testing.T) (*Service, *FakeAuthInfoStore, *MockSocialConnector) {
|
||||
func setupOAuthTokenService(t *testing.T) (*Service, *FakeAuthInfoStore, *socialtest.MockSocialConnector) {
|
||||
t.Helper()
|
||||
|
||||
socialConnector := &MockSocialConnector{}
|
||||
socialService := &FakeSocialService{
|
||||
connector: socialConnector,
|
||||
socialConnector := &socialtest.MockSocialConnector{}
|
||||
socialService := &socialtest.FakeSocialService{
|
||||
ExpectedConnector: socialConnector,
|
||||
}
|
||||
|
||||
authInfoStore := &FakeAuthInfoStore{}
|
||||
@ -239,74 +237,6 @@ func setupOAuthTokenService(t *testing.T) (*Service, *FakeAuthInfoStore, *MockSo
|
||||
}, authInfoStore, socialConnector
|
||||
}
|
||||
|
||||
type FakeSocialService struct {
|
||||
httpClient *http.Client
|
||||
connector *MockSocialConnector
|
||||
}
|
||||
|
||||
func (fss *FakeSocialService) GetOAuthProviders() map[string]bool {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (fss *FakeSocialService) GetOAuthHttpClient(string) (*http.Client, error) {
|
||||
return fss.httpClient, nil
|
||||
}
|
||||
|
||||
func (fss *FakeSocialService) GetConnector(string) (social.SocialConnector, error) {
|
||||
return fss.connector, nil
|
||||
}
|
||||
|
||||
func (fss *FakeSocialService) GetOAuthInfoProvider(string) *social.OAuthInfo {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (fss *FakeSocialService) GetOAuthInfoProviders() map[string]*social.OAuthInfo {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
type MockSocialConnector struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockSocialConnector) Type() int {
|
||||
args := m.Called()
|
||||
return args.Int(0)
|
||||
}
|
||||
|
||||
func (m *MockSocialConnector) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*social.BasicUserInfo, error) {
|
||||
args := m.Called(client, token)
|
||||
return args.Get(0).(*social.BasicUserInfo), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockSocialConnector) IsEmailAllowed(email string) bool {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *MockSocialConnector) IsSignupAllowed() bool {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *MockSocialConnector) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *MockSocialConnector) Exchange(ctx context.Context, code string, authOptions ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *MockSocialConnector) Client(ctx context.Context, t *oauth2.Token) *http.Client {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *MockSocialConnector) TokenSource(ctx context.Context, t *oauth2.Token) oauth2.TokenSource {
|
||||
args := m.Called(ctx, t)
|
||||
return args.Get(0).(oauth2.TokenSource)
|
||||
}
|
||||
|
||||
func (m *MockSocialConnector) SupportBundleContent(bf *bytes.Buffer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type FakeAuthInfoStore struct {
|
||||
login.Store
|
||||
ExpectedError error
|
||||
|
Loading…
Reference in New Issue
Block a user