OAuth: Feature toggle for access token expiration check and docs (#58179)

* Add feature toggle for access token expiration check

* Add docs for configuring refresh tokens

* Update docs

* Update docs based on review

Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com>

* Improve documentation

* Change access_type default to Offline

* Update docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md

Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com>

* Update docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md

Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com>

* Update pkg/services/featuremgmt/registry.go

Co-authored-by: Eric Leijonmarck <eric.leijonmarck@gmail.com>

* Regenerate toggles

* Update Generic OAuth docs

Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com>
Co-authored-by: Eric Leijonmarck <eric.leijonmarck@gmail.com>
pull/58717/head
Misi 3 years ago committed by GitHub
parent a9458c8c00
commit 4915d21c25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 12
      docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md
  2. 23
      docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md
  3. 8
      docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md
  4. 12
      docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md
  5. 12
      docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md
  6. 12
      docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md
  7. 13
      docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md
  8. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  9. 2
      pkg/api/common_test.go
  10. 3
      pkg/api/login_oauth.go
  11. 3
      pkg/middleware/middleware_test.go
  12. 2
      pkg/services/contexthandler/auth_proxy_test.go
  13. 51
      pkg/services/contexthandler/contexthandler.go
  14. 5
      pkg/services/featuremgmt/registry.go
  15. 4
      pkg/services/featuremgmt/toggles_gen.go

@ -169,6 +169,18 @@ GF_AUTH_AZUREAD_CLIENT_SECRET
**Note:** Verify that the Grafana [root_url]({{< relref "../../../configure-grafana/#root-url" >}}) is set in your Azure Application Redirect URLs. **Note:** Verify that the Grafana [root_url]({{< relref "../../../configure-grafana/#root-url" >}}) is set in your Azure Application Redirect URLs.
### Configure refresh token
> Available in Grafana v9.3 and later versions.
> **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle.
When a user logs in using an OAuth provider, Grafana verifies that the access token has not expired. When an access token expires, Grafana uses the provided refresh token (if any exists) to obtain a new access token.
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`.
### Configure allowed groups ### Configure allowed groups
To limit access to authenticated users who are members of one or more groups, set `allowed_groups` To limit access to authenticated users who are members of one or more groups, set `allowed_groups`

@ -116,9 +116,24 @@ use_pkce = true
Grafana always uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. Grafana always uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier.
### Configure refresh token
> Available in Grafana v9.3 and later versions.
> **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle.
When a user logs in using an OAuth provider, Grafana verifies that the access token has not expired. When an access token expires, Grafana uses the provided refresh token (if any exists) to obtain a new access token.
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 OAuth to use a refresh token, perform one or both of the following tasks, if required:
- Extend the `[auth.generic_oauth]` section with additional scopes
- Enable the refresh token on the provider
## Set up OAuth2 with Auth0 ## Set up OAuth2 with Auth0
1. Create a new Client in Auth0 1. Use the following parameters to create a client in Auth0:
- Name: Grafana - Name: Grafana
- Type: Regular Web Application - Type: Regular Web Application
@ -138,7 +153,7 @@ Grafana always uses the SHA256 based `S256` challenge method and a 128 bytes (ba
name = Auth0 name = Auth0
client_id = <client id> client_id = <client id>
client_secret = <client secret> client_secret = <client secret>
scopes = openid profile email scopes = openid profile email offline_access
auth_url = https://<domain>/authorize auth_url = https://<domain>/authorize
token_url = https://<domain>/oauth/token token_url = https://<domain>/oauth/token
api_url = https://<domain>/userinfo api_url = https://<domain>/userinfo
@ -164,6 +179,8 @@ team_ids =
allowed_organizations = allowed_organizations =
``` ```
By default, a refresh token is included in the response for the **Authorization Code Grant**.
## Set up OAuth2 with Centrify ## Set up OAuth2 with Centrify
1. Create a new Custom OpenID Connect application configuration in the Centrify dashboard. 1. Create a new Custom OpenID Connect application configuration in the Centrify dashboard.
@ -195,6 +212,8 @@ allowed_organizations =
api_url = https://<your domain>.my.centrify.com/OAuth2/UserInfo/<Application ID> api_url = https://<your domain>.my.centrify.com/OAuth2/UserInfo/<Application ID>
``` ```
By default, a refresh token is included in the response for the **Authorization Code Grant**.
## Set up OAuth2 with OneLogin ## Set up OAuth2 with OneLogin
1. Create a new Custom Connector with the following settings: 1. Create a new Custom Connector with the following settings:

@ -64,6 +64,14 @@ automatically signed up.
You can also use [variable expansion]({{< relref "../../../configure-grafana/#variable-expansion" >}}) to reference environment variables and local files in your GitHub auth configuration. You can also use [variable expansion]({{< relref "../../../configure-grafana/#variable-expansion" >}}) to reference environment variables and local files in your GitHub auth configuration.
### GitHub refresh token
> Available in Grafana v9.3 and later versions.
> **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle.
GitHub OAuth applications do not support refresh tokens because the provided access tokens do not expire.
### team_ids ### team_ids
Require an active team membership for at least one of the given teams on Require an active team membership for at least one of the given teams on

@ -81,6 +81,18 @@ to login on your Grafana instance.
You can limit access to only members of a given group or list of You can limit access to only members of a given group or list of
groups by setting the `allowed_groups` option. groups by setting the `allowed_groups` option.
### Configure refresh token
> Available in Grafana v9.3 and later versions.
> **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle.
When a user logs in using an OAuth provider, Grafana verifies that the access token has not expired. When an access token expires, Grafana uses the provided refresh token (if any exists) to obtain a new access token.
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.
By default, GitLab provides a refresh token.
### allowed_groups ### allowed_groups
To limit access to authenticated users that are members of one or more [GitLab To limit access to authenticated users that are members of one or more [GitLab

@ -53,3 +53,15 @@ You may allow users to sign-up via Google authentication by setting the
`allow_sign_up` option to `true`. When this option is set to `true`, any `allow_sign_up` option to `true`. When this option is set to `true`, any
user successfully authenticating via Google authentication will be user successfully authenticating via Google authentication will be
automatically signed up. automatically signed up.
### Configure refresh token
> Available in Grafana v9.3 and later versions.
> **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle.
When a user logs in using an OAuth provider, Grafana verifies that the access token has not expired. When an access token expires, Grafana uses the provided refresh token (if any exists) to obtain a new access token.
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.
By default, Grafana includes the `access_type=offline` parameter in the authorization request to request a refresh token.

@ -141,3 +141,15 @@ Grafana also assigns the user the `Admin` role of the default organization.
role_attribute_path = contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin' || contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer' role_attribute_path = contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin' || contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer'
allow_assign_grafana_admin = true allow_assign_grafana_admin = true
``` ```
### Configure refresh token
> Available in Grafana v9.3 and later versions.
> **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle.
When a user logs in using an OAuth provider, Grafana verifies that the access token has not expired. When an access token expires, Grafana uses the provided refresh token (if any exists) to obtain a new access token.
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 Keycloak, extend the `scopes` in `[auth.generic_oauth]` with `offline_access`.

@ -54,6 +54,19 @@ allowed_groups =
role_attribute_path = role_attribute_path =
``` ```
### Configure refresh token
> Available in Grafana v9.3 and later versions.
> **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle.
When a user logs in using an OAuth provider, Grafana verifies that the access token has not expired. When an access token expires, Grafana uses the provided refresh token (if any exists) to obtain a new access token.
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`.
### Configure allowed groups and domains ### Configure allowed groups and domains
To limit access to authenticated users that are members of one or more groups, set `allowed_groups` To limit access to authenticated users that are members of one or more groups, set `allowed_groups`

@ -80,5 +80,6 @@ export interface FeatureToggles {
datasourceLogger?: boolean; datasourceLogger?: boolean;
accessControlOnCall?: boolean; accessControlOnCall?: boolean;
nestedFolders?: boolean; nestedFolders?: boolean;
accessTokenExpirationCheck?: boolean;
elasticsearchBackendMigration?: boolean; elasticsearchBackendMigration?: boolean;
} }

@ -215,7 +215,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa
authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginservice.LoginServiceMock{}, &usertest.FakeUserService{}, sqlStore) authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginservice.LoginServiceMock{}, &usertest.FakeUserService{}, sqlStore)
loginService := &logintest.LoginServiceFake{} loginService := &logintest.LoginServiceFake{}
authenticator := &logintest.AuthenticatorFake{} authenticator := &logintest.AuthenticatorFake{}
ctxHdlr := contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator, usertest.NewUserServiceFake(), orgtest.NewOrgServiceFake(), nil) ctxHdlr := contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator, usertest.NewUserServiceFake(), orgtest.NewOrgServiceFake(), nil, featuremgmt.WithFeatures())
return ctxHdlr return ctxHdlr
} }

@ -97,7 +97,8 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) {
code := ctx.Query("code") code := ctx.Query("code")
if code == "" { if code == "" {
opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOnline} // FIXME: access_type is a Google OAuth2 specific thing, consider refactoring this and moving to google_oauth.go
opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}
if provider.UsePKCE { if provider.UsePKCE {
ascii, pkce, err := genPKCECode() ascii, pkce, err := genPKCECode()

@ -30,6 +30,7 @@ import (
"github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/contexthandler"
"github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/login/loginservice"
"github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/login/logintest"
"github.com/grafana/grafana/pkg/services/navtree" "github.com/grafana/grafana/pkg/services/navtree"
@ -832,7 +833,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg, mockSQLStore *dbtest.Fake
tracer := tracing.InitializeTracerForTest() tracer := tracing.InitializeTracerForTest()
authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginService, userService, mockSQLStore) authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginService, userService, mockSQLStore)
authenticator := &logintest.AuthenticatorFake{ExpectedUser: &user.User{}} authenticator := &logintest.AuthenticatorFake{ExpectedUser: &user.User{}}
return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy, loginService, apiKeyService, authenticator, userService, orgService, oauthTokenService) return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy, loginService, apiKeyService, authenticator, userService, orgService, oauthTokenService, featuremgmt.WithFeatures(featuremgmt.FlagAccessTokenExpirationCheck))
} }
type fakeRenderService struct { type fakeRenderService struct {

@ -104,7 +104,7 @@ func getContextHandler(t *testing.T) *ContextHandler {
return ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, return ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc,
renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator, renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator,
&userService, orgService, nil) &userService, orgService, nil, nil)
} }
type FakeGetSignUserStore struct { type FakeGetSignUserStore struct {

@ -24,6 +24,7 @@ import (
"github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy"
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/oauthtoken"
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
@ -46,7 +47,7 @@ func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtS
remoteCache *remotecache.RemoteCache, renderService rendering.Service, sqlStore db.DB, remoteCache *remotecache.RemoteCache, renderService rendering.Service, sqlStore db.DB,
tracer tracing.Tracer, authProxy *authproxy.AuthProxy, loginService login.Service, tracer tracing.Tracer, authProxy *authproxy.AuthProxy, loginService login.Service,
apiKeyService apikey.Service, authenticator loginpkg.Authenticator, userService user.Service, apiKeyService apikey.Service, authenticator loginpkg.Authenticator, userService user.Service,
orgService org.Service, oauthTokenService oauthtoken.OAuthTokenService, orgService org.Service, oauthTokenService oauthtoken.OAuthTokenService, features *featuremgmt.FeatureManager,
) *ContextHandler { ) *ContextHandler {
return &ContextHandler{ return &ContextHandler{
Cfg: cfg, Cfg: cfg,
@ -63,6 +64,7 @@ func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtS
userService: userService, userService: userService,
orgService: orgService, orgService: orgService,
oauthTokenService: oauthTokenService, oauthTokenService: oauthTokenService,
features: features,
} }
} }
@ -82,6 +84,7 @@ type ContextHandler struct {
userService user.Service userService user.Service
orgService org.Service orgService org.Service
oauthTokenService oauthtoken.OAuthTokenService oauthTokenService oauthtoken.OAuthTokenService
features *featuremgmt.FeatureManager
// GetTime returns the current time. // GetTime returns the current time.
// Stubbable by tests. // Stubbable by tests.
GetTime func() time.Time GetTime func() time.Time
@ -445,29 +448,31 @@ func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, org
getTime = time.Now getTime = time.Now
} }
// Check whether the logged in User has a token (whether the User used an OAuth provider to login) if h.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
oauthToken, exists, _ := h.oauthTokenService.HasOAuthEntry(ctx, queryResult) // Check whether the logged in User has a token (whether the User used an OAuth provider to login)
if exists { oauthToken, exists, _ := h.oauthTokenService.HasOAuthEntry(ctx, queryResult)
// Skip where the OAuthExpiry is default/zero/unset if exists {
if !oauthToken.OAuthExpiry.IsZero() && oauthToken.OAuthExpiry.Round(0).Add(-oauthtoken.ExpiryDelta).Before(getTime()) { // Skip where the OAuthExpiry is default/zero/unset
reqContext.Logger.Info("access token expired", "userId", query.UserID, "expiry", fmt.Sprintf("%v", oauthToken.OAuthExpiry)) if !oauthToken.OAuthExpiry.IsZero() && oauthToken.OAuthExpiry.Round(0).Add(-oauthtoken.ExpiryDelta).Before(getTime()) {
reqContext.Logger.Info("access token expired", "userId", query.UserID, "expiry", fmt.Sprintf("%v", oauthToken.OAuthExpiry))
// If the User doesn't have a refresh_token or refreshing the token was unsuccessful then log out the User and Invalidate the OAuth tokens
if err = h.oauthTokenService.TryTokenRefresh(ctx, oauthToken); err != nil { // If the User doesn't have a refresh_token or refreshing the token was unsuccessful then log out the User and Invalidate the OAuth tokens
if !errors.Is(err, oauthtoken.ErrNoRefreshTokenFound) { if err = h.oauthTokenService.TryTokenRefresh(ctx, oauthToken); err != nil {
reqContext.Logger.Error("could not fetch a new access token", "userId", oauthToken.UserId, "error", err) if !errors.Is(err, oauthtoken.ErrNoRefreshTokenFound) {
} reqContext.Logger.Error("could not fetch a new access token", "userId", oauthToken.UserId, "error", err)
}
reqContext.Resp.Before(h.deleteInvalidCookieEndOfRequestFunc(reqContext))
if err = h.oauthTokenService.InvalidateOAuthTokens(ctx, oauthToken); err != nil { reqContext.Resp.Before(h.deleteInvalidCookieEndOfRequestFunc(reqContext))
reqContext.Logger.Error("could not invalidate OAuth tokens", "userId", oauthToken.UserId, "error", err) if err = h.oauthTokenService.InvalidateOAuthTokens(ctx, oauthToken); err != nil {
} reqContext.Logger.Error("could not invalidate OAuth tokens", "userId", oauthToken.UserId, "error", err)
}
err = h.AuthTokenService.RevokeToken(ctx, token, false)
if err != nil && !errors.Is(err, models.ErrUserTokenNotFound) { err = h.AuthTokenService.RevokeToken(ctx, token, false)
reqContext.Logger.Error("failed to revoke auth token", "error", err) if err != nil && !errors.Is(err, models.ErrUserTokenNotFound) {
reqContext.Logger.Error("failed to revoke auth token", "error", err)
}
return false
} }
return false
} }
} }
} }

@ -357,6 +357,11 @@ var (
State: FeatureStateAlpha, State: FeatureStateAlpha,
RequiresDevMode: true, RequiresDevMode: true,
}, },
{
Name: "accessTokenExpirationCheck",
Description: "Enable OAuth access_token expiration check and token refresh using the refresh_token",
State: FeatureStateStable,
},
{ {
Name: "elasticsearchBackendMigration", Name: "elasticsearchBackendMigration",
Description: "Use Elasticsearch as backend data source", Description: "Use Elasticsearch as backend data source",

@ -263,6 +263,10 @@ const (
// Enable folder nesting // Enable folder nesting
FlagNestedFolders = "nestedFolders" FlagNestedFolders = "nestedFolders"
// FlagAccessTokenExpirationCheck
// Enable OAuth access_token expiration check and token refresh using the refresh_token
FlagAccessTokenExpirationCheck = "accessTokenExpirationCheck"
// FlagElasticsearchBackendMigration // FlagElasticsearchBackendMigration
// Use Elasticsearch as backend data source // Use Elasticsearch as backend data source
FlagElasticsearchBackendMigration = "elasticsearchBackendMigration" FlagElasticsearchBackendMigration = "elasticsearchBackendMigration"

Loading…
Cancel
Save