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.
### 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
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.
### 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
1. Create a new Client in Auth0
1. Use the following parameters to create a client in Auth0:
- Name: Grafana
- Type: Regular Web Application
@ -138,7 +153,7 @@ Grafana always uses the SHA256 based `S256` challenge method and a 128 bytes (ba
name = Auth0
client_id = <client id>
client_secret = <client secret>
scopes = openid profile email
scopes = openid profile email offline_access
auth_url = https://<domain>/authorize
token_url = https://<domain>/oauth/token
api_url = https://<domain>/userinfo
@ -164,6 +179,8 @@ team_ids =
allowed_organizations =
```
By default, a refresh token is included in the response for the **Authorization Code Grant**.
## Set up OAuth2 with Centrify
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>
```
By default, a refresh token is included in the response for the **Authorization Code Grant**.
## Set up OAuth2 with OneLogin
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.
### 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
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
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
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
user successfully authenticating via Google authentication will be
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'
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 =
```
### 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
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;
accessControlOnCall?: boolean;
nestedFolders?: boolean;
accessTokenExpirationCheck?: 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)
loginService := &logintest.LoginServiceFake{}
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
}

@ -97,7 +97,8 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) {
code := ctx.Query("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 {
ascii, pkce, err := genPKCECode()

@ -30,6 +30,7 @@ import (
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/contexthandler"
"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/logintest"
"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()
authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginService, userService, mockSQLStore)
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 {

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

@ -24,6 +24,7 @@ import (
"github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/contexthandler/authproxy"
"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/oauthtoken"
"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,
tracer tracing.Tracer, authProxy *authproxy.AuthProxy, loginService login.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 {
return &ContextHandler{
Cfg: cfg,
@ -63,6 +64,7 @@ func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtS
userService: userService,
orgService: orgService,
oauthTokenService: oauthTokenService,
features: features,
}
}
@ -82,6 +84,7 @@ type ContextHandler struct {
userService user.Service
orgService org.Service
oauthTokenService oauthtoken.OAuthTokenService
features *featuremgmt.FeatureManager
// GetTime returns the current time.
// Stubbable by tests.
GetTime func() time.Time
@ -445,29 +448,31 @@ func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, org
getTime = time.Now
}
// Check whether the logged in User has a token (whether the User used an OAuth provider to login)
oauthToken, exists, _ := h.oauthTokenService.HasOAuthEntry(ctx, queryResult)
if exists {
// Skip where the OAuthExpiry is default/zero/unset
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 !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.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) {
reqContext.Logger.Error("failed to revoke auth token", "error", err)
if h.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
// Check whether the logged in User has a token (whether the User used an OAuth provider to login)
oauthToken, exists, _ := h.oauthTokenService.HasOAuthEntry(ctx, queryResult)
if exists {
// Skip where the OAuthExpiry is default/zero/unset
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 !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.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) {
reqContext.Logger.Error("failed to revoke auth token", "error", err)
}
return false
}
return false
}
}
}

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

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

Loading…
Cancel
Save