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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
|
Generated
+4
@@ -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
@@ -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",
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user