IAM: Optionally make refresh tokens required if use_refresh_token is enabled (#114174)

* OAuth: Optionally make refresh tokens required if use_refresh_token is enabled

* make linter happy

* feedback: log missing refresh token during token refresh

* feedback: tweak wording in the message & change level
This commit is contained in:
Victor Cinaglia
2025-11-19 15:21:40 -03:00
committed by GitHub
parent f074431a36
commit 164873591b
8 changed files with 103 additions and 0 deletions
+4
View File
@@ -477,6 +477,10 @@ export interface FeatureToggles {
*/
oauthRequireSubClaim?: boolean;
/**
* Require that refresh tokens are present in oauth tokens.
*/
refreshTokenRequired?: boolean;
/**
* Enables filters and group by variables on all new dashboards. Variables are added only if default data source supports filtering.
*/
newDashboardWithFiltersAndGroupBy?: boolean;
+11
View File
@@ -55,6 +55,8 @@ var (
errOAuthTokenExchange = errutil.Internal("auth.oauth.token.exchange", errutil.WithPublicMessage("Failed to get token from provider"))
errOAuthUserInfo = errutil.Internal("auth.oauth.userinfo.error")
errOAuthMissingRefreshToken = errutil.Unauthorized("auth.oauth.token.refresh-token.missing", errutil.WithPublicMessage("Provider did not return a refresh token"))
errOAuthMissingRequiredEmail = errutil.Unauthorized("auth.oauth.email.missing", errutil.WithPublicMessage("Provider didn't return an email address"))
errOAuthEmailNotAllowed = errutil.Unauthorized("auth.oauth.email.not-allowed", errutil.WithPublicMessage("Required email domain not fulfilled"))
)
@@ -166,6 +168,15 @@ func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden
}
token.TokenType = "Bearer"
if oauthCfg.UseRefreshToken && token.RefreshToken == "" {
c.log.FromContext(ctx).Warn("No refresh token available with use_refresh_token enabled", "authmodule", c.moduleName)
//nolint:staticcheck // not yet migrated to OpenFeature
if c.features.IsEnabledGlobally(featuremgmt.FlagRefreshTokenRequired) {
return nil, errOAuthMissingRefreshToken.Errorf("provider did not return a refresh token")
}
}
userInfo, err := connector.UserInfo(ctx, connector.Client(clientCtx, token), token)
if err != nil {
var sErr *connectors.SocialError
+57
View File
@@ -265,6 +265,63 @@ func TestOAuth_Authenticate(t *testing.T) {
},
},
},
{
desc: "should return error when no refresh token is available and feature toggle is enabled",
req: &authn.Request{
HTTPRequest: &http.Request{
Header: map[string][]string{},
URL: mustParseURL("http://grafana.com/?state=some-state"),
},
},
oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true, UseRefreshToken: true},
features: []any{featuremgmt.FlagRefreshTokenRequired},
allowInsecureTakeover: true,
addStateCookie: true,
stateCookieValue: "some-state",
addPKCECookie: true,
pkceCookieValue: "some-pkce-value",
isEmailAllowed: true,
expectedErr: errOAuthMissingRefreshToken,
},
{
desc: "should return identity when no refresh token is available and feature toggle is disabled",
req: &authn.Request{
HTTPRequest: &http.Request{
Header: map[string][]string{},
URL: mustParseURL("http://grafana.com/?state=some-state"),
},
},
oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true, UseRefreshToken: true},
addStateCookie: true,
stateCookieValue: "some-state",
addPKCECookie: true,
pkceCookieValue: "some-pkce-value",
isEmailAllowed: true,
userInfo: &social.BasicUserInfo{
Id: "123",
Name: "name",
Email: "some@email.com",
Role: "Admin",
Groups: []string{"grp1", "grp2"},
},
expectedIdentity: &authn.Identity{
Email: "some@email.com",
AuthenticatedBy: login.AzureADAuthModule,
AuthID: "123",
Name: "name",
Groups: []string{"grp1", "grp2"},
OAuthToken: &oauth2.Token{},
OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin},
ClientParams: authn.ClientParams{
SyncUser: true,
SyncTeams: true,
AllowSignUp: true,
FetchSyncedUser: true,
SyncOrgRoles: true,
LookUpParams: login.UserLookupParams{},
},
},
},
}
for _, tt := range tests {
+8
View File
@@ -817,6 +817,14 @@ var (
HideFromDocs: true,
HideFromAdminPage: true,
},
{
Name: "refreshTokenRequired",
Description: "Require that refresh tokens are present in oauth tokens.",
Stage: FeatureStageExperimental,
Owner: identityAccessTeam,
HideFromDocs: true,
HideFromAdminPage: true,
},
{
Name: "newDashboardWithFiltersAndGroupBy",
Description: "Enables filters and group by variables on all new dashboards. Variables are added only if default data source supports filtering.",
+1
View File
@@ -107,6 +107,7 @@ kubernetesAggregatorCapTokenAuth,experimental,@grafana/grafana-app-platform-squa
groupByVariable,experimental,@grafana/dashboards-squad,false,false,false
scopeFilters,experimental,@grafana/dashboards-squad,false,false,false
oauthRequireSubClaim,experimental,@grafana/identity-access-team,false,false,false
refreshTokenRequired,experimental,@grafana/identity-access-team,false,false,false
newDashboardWithFiltersAndGroupBy,experimental,@grafana/dashboards-squad,false,false,false
cloudWatchNewLabelParsing,GA,@grafana/aws-datasources,false,false,false
disableNumericMetricsSortingInExpressions,experimental,@grafana/oss-big-tent,false,true,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
107 groupByVariable experimental @grafana/dashboards-squad false false false
108 scopeFilters experimental @grafana/dashboards-squad false false false
109 oauthRequireSubClaim experimental @grafana/identity-access-team false false false
110 refreshTokenRequired experimental @grafana/identity-access-team false false false
111 newDashboardWithFiltersAndGroupBy experimental @grafana/dashboards-squad false false false
112 cloudWatchNewLabelParsing GA @grafana/aws-datasources false false false
113 disableNumericMetricsSortingInExpressions experimental @grafana/oss-big-tent false true false
+4
View File
@@ -439,6 +439,10 @@ const (
// Require that sub claims is present in oauth tokens.
FlagOauthRequireSubClaim = "oauthRequireSubClaim"
// FlagRefreshTokenRequired
// Require that refresh tokens are present in oauth tokens.
FlagRefreshTokenRequired = "refreshTokenRequired"
// FlagNewDashboardWithFiltersAndGroupBy
// Enables filters and group by variables on all new dashboards. Variables are added only if default data source supports filtering.
FlagNewDashboardWithFiltersAndGroupBy = "newDashboardWithFiltersAndGroupBy"
+14
View File
@@ -3543,6 +3543,20 @@
"hideFromAdminPage": true
}
},
{
"metadata": {
"name": "refreshTokenRequired",
"resourceVersion": "1763561990273",
"creationTimestamp": "2025-11-19T14:19:50Z"
},
"spec": {
"description": "Require that refresh tokens are present in oauth tokens.",
"stage": "experimental",
"codeowner": "@grafana/identity-access-team",
"hideFromAdminPage": true,
"hideFromDocs": true
}
},
{
"metadata": {
"name": "regressionTransformation",
+4
View File
@@ -473,6 +473,10 @@ func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken
)
}
if token.RefreshToken == "" {
ctxLogger.Warn("Refresh token is missing after token refresh", "authmodule", tokenRefreshMetadata.AuthModule)
}
//nolint:staticcheck // not yet migrated to OpenFeature
if !o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) {
updateAuthCommand := &login.UpdateAuthInfoCommand{