SSO: Add prompt param to SSO settings (#107969)

* add prompt param to AzureAD oauth config

* yarn i18n-extract

* validate auth prompt value

* make login_prompt available for all SSO providers

* use base authCodeURL for azure and google

* add docs for the new field for azure and generic oauth

* fix typo

* fix frontend unit test

* add prompt parameter to docs for the other providers

* remove prompt from okta

* add unit tests for the other providers

* address feedback

* add back translations for prompt labels
This commit is contained in:
Mihai Doarna
2025-07-17 14:40:48 +03:00
committed by GitHub
parent 807264428e
commit 8dfb4cdfc9
22 changed files with 169 additions and 6 deletions

View File

@ -544,6 +544,7 @@ The following table outlines the various Azure AD/Entra ID configuration options
| `scopes` | No | Yes | List of comma- or space-separated OAuth2 scopes. | `openid email profile` | | `scopes` | No | Yes | List of comma- or space-separated OAuth2 scopes. | `openid email profile` |
| `allow_sign_up` | No | Yes | Controls Grafana user creation through the Azure AD/Entra ID login. Only existing Grafana users can log in with Azure AD/Entra ID if set to `false`. | `true` | | `allow_sign_up` | No | Yes | Controls Grafana user creation through the Azure AD/Entra ID login. Only existing Grafana users can log in with Azure AD/Entra ID if set to `false`. | `true` |
| `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | | `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` |
| `login_prompt` | No | Yes | Indicates the type of user interaction when the user logs in with Azure AD/Entra ID. Available values are `login`, `consent` and `select_account`. | |
| `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Map roles](#map-roles). | `false` | | `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Map roles](#map-roles). | `false` |
| `org_attribute_path` | No | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana org to role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no value is returned, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation will be mapped to org roles based on `org_mapping`. For more information on org to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | | | `org_attribute_path` | No | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana org to role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no value is returned, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation will be mapped to org roles based on `org_mapping`. For more information on org to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | |
| `org_mapping` | No | No | List of comma- or space-separated `<ExternalOrgName>:<OrgIdOrName>:<Role>` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | | | `org_mapping` | No | No | List of comma- or space-separated `<ExternalOrgName>:<OrgIdOrName>:<Role>` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | |

View File

@ -376,6 +376,7 @@ If the configuration option requires a JMESPath expression that includes a colon
| `empty_scopes` | No | Yes | Set to `true` to use an empty scope during authentication. | `false` | | `empty_scopes` | No | Yes | Set to `true` to use an empty scope during authentication. | `false` |
| `allow_sign_up` | No | Yes | Controls Grafana user creation through the Generic OAuth login. Only existing Grafana users can log in with Generic OAuth if set to `false`. | `true` | | `allow_sign_up` | No | Yes | Controls Grafana user creation through the Generic OAuth login. Only existing Grafana users can log in with Generic OAuth if set to `false`. | `true` |
| `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | | `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` |
| `login_prompt` | No | Yes | Indicates the type of user interaction when the user logs in with the IdP. Available values are `login`, `consent` and `select_account`. | |
| `id_token_attribute_name` | No | Yes | The name of the key used to extract the ID token from the returned OAuth2 token. | `id_token` | | `id_token_attribute_name` | No | Yes | The name of the key used to extract the ID token from the returned OAuth2 token. | `id_token` |
| `login_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user login lookup from the user ID token. For more information on how user login is retrieved, refer to [Configure login](#configure-login). | | | `login_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user login lookup from the user ID token. For more information on how user login is retrieved, refer to [Configure login](#configure-login). | |
| `name_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user name lookup from the user ID token. This name will be used as the user's display name. For more information on how user display name is retrieved, refer to [Configure display name](#configure-display-name). | | | `name_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user name lookup from the user ID token. This name will be used as the user's display name. For more information on how user display name is retrieved, refer to [Configure display name](#configure-display-name). | |

View File

@ -247,6 +247,7 @@ If the configuration option requires a JMESPath expression that includes a colon
| `scopes` | No | Yes | List of comma- or space-separated GitHub OAuth scopes. | `user:email,read:org` | | `scopes` | No | Yes | List of comma- or space-separated GitHub OAuth scopes. | `user:email,read:org` |
| `allow_sign_up` | No | Yes | Whether to allow new Grafana user creation through GitHub login. If set to `false`, then only existing Grafana users can log in with GitHub OAuth. | `true` | | `allow_sign_up` | No | Yes | Whether to allow new Grafana user creation through GitHub login. If set to `false`, then only existing Grafana users can log in with GitHub OAuth. | `true` |
| `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | | `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` |
| `login_prompt` | No | Yes | Indicates the type of user interaction when the user logs in with GitHub. Available values are `login`, `consent` and `select_account`. | |
| `role_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the user information obtained from the UserInfo endpoint. If no role is found, Grafana creates a JSON data with `groups` key that maps to GitHub teams obtained from GitHub's [`/api/user/teams`](https://docs.github.com/en/rest/teams/teams#list-teams-for-the-authenticated-user) endpoint, and evaluates the expression using this data. The result of the evaluation should be a valid Grafana role (`None`, `Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping](#org-roles-mapping-example). | | | `role_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the user information obtained from the UserInfo endpoint. If no role is found, Grafana creates a JSON data with `groups` key that maps to GitHub teams obtained from GitHub's [`/api/user/teams`](https://docs.github.com/en/rest/teams/teams#list-teams-for-the-authenticated-user) endpoint, and evaluates the expression using this data. The result of the evaluation should be a valid Grafana role (`None`, `Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping](#org-roles-mapping-example). | |
| `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Configure role mapping](#org-roles-mapping-example). | `false` | | `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Configure role mapping](#org-roles-mapping-example). | `false` |
| `org_mapping` | No | No | List of comma- or space-separated `<ExternalGitHubTeamName>:<OrgIdOrName>:<Role>` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | | | `org_mapping` | No | No | List of comma- or space-separated `<ExternalGitHubTeamName>:<OrgIdOrName>:<Role>` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | |

View File

@ -270,6 +270,7 @@ If the configuration option requires a JMESPath expression that includes a colon
| `scopes` | No | Yes | List of comma or space-separated GitLab OAuth scopes. | `openid email profile` | | `scopes` | No | Yes | List of comma or space-separated GitLab OAuth scopes. | `openid email profile` |
| `allow_sign_up` | No | Yes | Whether to allow new Grafana user creation through GitLab login. If set to `false`, then only existing Grafana users can log in with GitLab OAuth. | `true` | | `allow_sign_up` | No | Yes | Whether to allow new Grafana user creation through GitLab login. If set to `false`, then only existing Grafana users can log in with GitLab OAuth. | `true` |
| `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | | `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` |
| `login_prompt` | No | Yes | Indicates the type of user interaction when the user logs in with GitLab. Available values are `login`, `consent` and `select_account`. | |
| `role_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the GitLab OAuth token. If no role is found, Grafana creates a JSON data with `groups` key that maps to groups obtained from GitLab's `/oauth/userinfo` endpoint, and evaluates the expression using this data. Finally, if a valid role is still not found, the expression is evaluated against the user information retrieved from `api_url/users` endpoint and groups retrieved from `api_url/groups` endpoint. The result of the evaluation should be a valid Grafana role (`None`, `Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | | | `role_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the GitLab OAuth token. If no role is found, Grafana creates a JSON data with `groups` key that maps to groups obtained from GitLab's `/oauth/userinfo` endpoint, and evaluates the expression using this data. Finally, if a valid role is still not found, the expression is evaluated against the user information retrieved from `api_url/users` endpoint and groups retrieved from `api_url/groups` endpoint. The result of the evaluation should be a valid Grafana role (`None`, `Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | |
| `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana role cannot be extracted using `role_attribute_path`. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` | | `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana role cannot be extracted using `role_attribute_path`. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` |
| `org_mapping` | No | No | List of comma- or space-separated `<ExternalGitlabGroupName>:<OrgIdOrName>:<Role>` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | | | `org_mapping` | No | No | List of comma- or space-separated `<ExternalGitlabGroupName>:<OrgIdOrName>:<Role>` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | |

View File

@ -290,6 +290,7 @@ The following table outlines the various Google OAuth configuration options. You
| `scopes` | No | Yes | List of comma- or space-separated OAuth2 scopes. | `openid email profile` | | `scopes` | No | Yes | List of comma- or space-separated OAuth2 scopes. | `openid email profile` |
| `allow_sign_up` | No | Yes | Controls Grafana user creation through the Google login. Only existing Grafana users can log in with Google if set to `false`. | `true` | | `allow_sign_up` | No | Yes | Controls Grafana user creation through the Google login. Only existing Grafana users can log in with Google if set to `false`. | `true` |
| `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | | `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` |
| `login_prompt` | No | Yes | Indicates the type of user interaction when the user logs in with Google. Available values are `login`, `consent` and `select_account`. | |
| `hosted_domain` | No | Yes | Specifies the domain to restrict access to users from that domain. This value is appended to the authorization request using the `hd` parameter. | | | `hosted_domain` | No | Yes | Specifies the domain to restrict access to users from that domain. This value is appended to the authorization request using the `hd` parameter. | |
| `validate_hd` | No | Yes | Set to `false` to disable the validation of the `hd` parameter from the Google ID token. For more informatiion, refer to [Enable Google OAuth in Grafana](#enable-google-oauth-in-grafana). | `true` | | `validate_hd` | No | Yes | Set to `false` to disable the validation of the `hd` parameter from the Google ID token. For more informatiion, refer to [Enable Google OAuth in Grafana](#enable-google-oauth-in-grafana). | `true` |
| `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` | | `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` |

View File

@ -336,7 +336,7 @@ func (s *SocialAzureAD) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption)
opts = append(opts, oauth2.SetAuthURLParam("domain_hint", domainHint)) opts = append(opts, oauth2.SetAuthURLParam("domain_hint", domainHint))
} }
return s.Config.AuthCodeURL(state, opts...) return s.getAuthCodeURL(state, opts...)
} }
func (s *SocialAzureAD) validateIDTokenSignature(ctx context.Context, client *http.Client, parsedToken *jwt.JSONWebToken) (*azureClaims, error) { func (s *SocialAzureAD) validateIDTokenSignature(ctx context.Context, client *http.Client, parsedToken *jwt.JSONWebToken) (*azureClaims, error) {

View File

@ -1276,6 +1276,22 @@ func TestSocialAzureAD_Validate(t *testing.T) {
}, },
wantErr: ssosettings.ErrBaseInvalidOAuthConfig, wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
}, },
{
name: "fails if login prompt is invalid",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_authentication": "client_secret_post",
"client_id": "client-id",
"client_secret": "client_secret",
"allowed_groups": "0bb9c9cc-4945-418f-9b6a-c1d3b81141b0, 6034d328-0e6a-4240-8d03-cb9f2c1f16e4",
"allow_assign_grafana_admin": "true",
"auth_url": "https://example.com/auth",
"token_url": "https://example.com/token",
"login_prompt": "invalid",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
} }
for _, tc := range testCases { for _, tc := range testCases {
@ -1315,6 +1331,7 @@ func TestSocialAzureAD_Reload(t *testing.T) {
"client_id": "new-client-id", "client_id": "new-client-id",
"client_secret": "new-client-secret", "client_secret": "new-client-secret",
"auth_url": "some-new-url", "auth_url": "some-new-url",
"login_prompt": "select_account",
}, },
}, },
expectError: false, expectError: false,
@ -1322,6 +1339,7 @@ func TestSocialAzureAD_Reload(t *testing.T) {
ClientId: "new-client-id", ClientId: "new-client-id",
ClientSecret: "new-client-secret", ClientSecret: "new-client-secret",
AuthUrl: "some-new-url", AuthUrl: "some-new-url",
LoginPrompt: "select_account",
}, },
expectedConfig: &oauth2.Config{ expectedConfig: &oauth2.Config{
ClientID: "new-client-id", ClientID: "new-client-id",

View File

@ -1230,6 +1230,20 @@ func TestSocialGenericOAuth_Validate(t *testing.T) {
}, },
wantErr: ssosettings.ErrBaseInvalidOAuthConfig, wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
}, },
{
name: "fails if login prompt is invalid",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"teams_url": "https://example.com/teams",
"auth_url": "https://example.com/auth",
"token_url": "https://example.com/token",
"login_prompt": "invalid",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
} }
for _, tc := range testCases { for _, tc := range testCases {
@ -1269,6 +1283,7 @@ func TestSocialGenericOAuth_Reload(t *testing.T) {
"client_id": "new-client-id", "client_id": "new-client-id",
"client_secret": "new-client-secret", "client_secret": "new-client-secret",
"auth_url": "some-new-url", "auth_url": "some-new-url",
"login_prompt": "login",
}, },
}, },
expectError: false, expectError: false,
@ -1276,6 +1291,7 @@ func TestSocialGenericOAuth_Reload(t *testing.T) {
ClientId: "new-client-id", ClientId: "new-client-id",
ClientSecret: "new-client-secret", ClientSecret: "new-client-secret",
AuthUrl: "some-new-url", AuthUrl: "some-new-url",
LoginPrompt: "login",
}, },
expectedConfig: &oauth2.Config{ expectedConfig: &oauth2.Config{
ClientID: "new-client-id", ClientID: "new-client-id",
@ -1357,6 +1373,7 @@ func TestGenericOAuth_Reload_ExtraFields(t *testing.T) {
EmailAttributeName: "email-attr-name", EmailAttributeName: "email-attr-name",
GroupsAttributePath: "groups-attr-path", GroupsAttributePath: "groups-attr-path",
TeamIdsAttributePath: "team-ids-attr-path", TeamIdsAttributePath: "team-ids-attr-path",
LoginPrompt: "login",
Extra: map[string]string{ Extra: map[string]string{
teamIdsKey: "team1", teamIdsKey: "team1",
allowedOrganizationsKey: "org1", allowedOrganizationsKey: "org1",
@ -1374,6 +1391,7 @@ func TestGenericOAuth_Reload_ExtraFields(t *testing.T) {
"email_attribute_name": "new-email-attr-name", "email_attribute_name": "new-email-attr-name",
"groups_attribute_path": "new-group-attr-path", "groups_attribute_path": "new-group-attr-path",
"team_ids_attribute_path": "new-team-ids-attr-path", "team_ids_attribute_path": "new-team-ids-attr-path",
"login_prompt": "select_account",
teamIdsKey: "team1,team2", teamIdsKey: "team1,team2",
allowedOrganizationsKey: "org1,org2", allowedOrganizationsKey: "org1,org2",
loginAttributePathKey: "new-login-attr-path", loginAttributePathKey: "new-login-attr-path",
@ -1389,6 +1407,7 @@ func TestGenericOAuth_Reload_ExtraFields(t *testing.T) {
EmailAttributeName: "new-email-attr-name", EmailAttributeName: "new-email-attr-name",
GroupsAttributePath: "new-group-attr-path", GroupsAttributePath: "new-group-attr-path",
TeamIdsAttributePath: "new-team-ids-attr-path", TeamIdsAttributePath: "new-team-ids-attr-path",
LoginPrompt: "select_account",
Extra: map[string]string{ Extra: map[string]string{
teamIdsKey: "team1,team2", teamIdsKey: "team1,team2",
allowedOrganizationsKey: "org1,org2", allowedOrganizationsKey: "org1,org2",

View File

@ -495,6 +495,7 @@ func TestSocialGitHub_Validate(t *testing.T) {
"auth_url": "", "auth_url": "",
"token_url": "", "token_url": "",
"api_url": "", "api_url": "",
"login_prompt": "select_account",
}, },
}, },
requester: &user.SignedInUser{IsGrafanaAdmin: true}, requester: &user.SignedInUser{IsGrafanaAdmin: true},
@ -594,6 +595,17 @@ func TestSocialGitHub_Validate(t *testing.T) {
}, },
wantErr: ssosettings.ErrBaseInvalidOAuthConfig, wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
}, },
{
name: "fails if login prompt is invalid",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"login_prompt": "invalid",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
} }
for _, tc := range testCases { for _, tc := range testCases {
@ -634,6 +646,7 @@ func TestSocialGitHub_Reload(t *testing.T) {
"client_id": "new-client-id", "client_id": "new-client-id",
"client_secret": "new-client-secret", "client_secret": "new-client-secret",
"auth_url": "some-new-url", "auth_url": "some-new-url",
"login_prompt": "login",
}, },
}, },
expectError: false, expectError: false,
@ -641,6 +654,7 @@ func TestSocialGitHub_Reload(t *testing.T) {
ClientId: "new-client-id", ClientId: "new-client-id",
ClientSecret: "new-client-secret", ClientSecret: "new-client-secret",
AuthUrl: "some-new-url", AuthUrl: "some-new-url",
LoginPrompt: "login",
}, },
expectedConfig: &oauth2.Config{ expectedConfig: &oauth2.Config{
ClientID: "new-client-id", ClientID: "new-client-id",

View File

@ -547,6 +547,7 @@ func TestSocialGitlab_Validate(t *testing.T) {
"auth_url": "", "auth_url": "",
"token_url": "", "token_url": "",
"api_url": "", "api_url": "",
"login_prompt": "select_account",
}, },
}, },
requester: &user.SignedInUser{IsGrafanaAdmin: true}, requester: &user.SignedInUser{IsGrafanaAdmin: true},
@ -640,6 +641,17 @@ func TestSocialGitlab_Validate(t *testing.T) {
}, },
wantErr: ssosettings.ErrBaseInvalidOAuthConfig, wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
}, },
{
name: "fails if login prompt is invalid",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"login_prompt": "invalid",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
} }
for _, tc := range testCases { for _, tc := range testCases {
@ -680,6 +692,7 @@ func TestSocialGitlab_Reload(t *testing.T) {
"client_id": "new-client-id", "client_id": "new-client-id",
"client_secret": "new-client-secret", "client_secret": "new-client-secret",
"auth_url": "some-new-url", "auth_url": "some-new-url",
"login_prompt": "login",
}, },
}, },
expectError: false, expectError: false,
@ -687,6 +700,7 @@ func TestSocialGitlab_Reload(t *testing.T) {
ClientId: "new-client-id", ClientId: "new-client-id",
ClientSecret: "new-client-secret", ClientSecret: "new-client-secret",
AuthUrl: "some-new-url", AuthUrl: "some-new-url",
LoginPrompt: "login",
}, },
expectedConfig: &oauth2.Config{ expectedConfig: &oauth2.Config{
ClientID: "new-client-id", ClientID: "new-client-id",

View File

@ -224,7 +224,8 @@ func (s *SocialGoogle) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption)
if s.info.UseRefreshToken { if s.info.UseRefreshToken {
opts = append(opts, oauth2.AccessTypeOffline, oauth2.ApprovalForce) opts = append(opts, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
} }
return s.Config.AuthCodeURL(state, opts...)
return s.getAuthCodeURL(state, opts...)
} }
func (s *SocialGoogle) extractFromToken(_ context.Context, _ *http.Client, token *oauth2.Token) (*googleUserData, error) { func (s *SocialGoogle) extractFromToken(_ context.Context, _ *http.Client, token *oauth2.Token) (*googleUserData, error) {

View File

@ -724,6 +724,7 @@ func TestSocialGoogle_Validate(t *testing.T) {
"auth_url": "", "auth_url": "",
"token_url": "", "token_url": "",
"api_url": "", "api_url": "",
"login_prompt": "select_account",
}, },
}, },
requester: &user.SignedInUser{IsGrafanaAdmin: true}, requester: &user.SignedInUser{IsGrafanaAdmin: true},
@ -830,6 +831,17 @@ func TestSocialGoogle_Validate(t *testing.T) {
}, },
wantErr: ssosettings.ErrBaseInvalidOAuthConfig, wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
}, },
{
name: "fails if login prompt is invalid",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"login_prompt": "invalid",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
} }
for _, tc := range testCases { for _, tc := range testCases {
@ -870,6 +882,7 @@ func TestSocialGoogle_Reload(t *testing.T) {
"client_id": "new-client-id", "client_id": "new-client-id",
"client_secret": "new-client-secret", "client_secret": "new-client-secret",
"auth_url": "some-new-url", "auth_url": "some-new-url",
"login_prompt": "login",
}, },
}, },
expectError: false, expectError: false,
@ -877,6 +890,7 @@ func TestSocialGoogle_Reload(t *testing.T) {
ClientId: "new-client-id", ClientId: "new-client-id",
ClientSecret: "new-client-secret", ClientSecret: "new-client-secret",
AuthUrl: "some-new-url", AuthUrl: "some-new-url",
LoginPrompt: "login",
}, },
expectedConfig: &oauth2.Config{ expectedConfig: &oauth2.Config{
ClientID: "new-client-id", ClientID: "new-client-id",

View File

@ -85,6 +85,15 @@ func (s *SocialBase) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) st
s.reloadMutex.RLock() s.reloadMutex.RLock()
defer s.reloadMutex.RUnlock() defer s.reloadMutex.RUnlock()
return s.getAuthCodeURL(state, opts...)
}
func (s *SocialBase) getAuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
if s.info.LoginPrompt != "" {
promptOpt := oauth2.SetAuthURLParam("prompt", s.info.LoginPrompt)
opts = append(opts, promptOpt)
}
return s.Config.AuthCodeURL(state, opts...) return s.Config.AuthCodeURL(state, opts...)
} }
@ -268,5 +277,7 @@ func validateInfo(info *social.OAuthInfo, oldInfo *social.OAuthInfo, requester i
validation.AllowAssignGrafanaAdminValidator(info, oldInfo, requester), validation.AllowAssignGrafanaAdminValidator(info, oldInfo, requester),
validation.SkipOrgRoleSyncAllowAssignGrafanaAdminValidator, validation.SkipOrgRoleSyncAllowAssignGrafanaAdminValidator,
validation.OrgAttributePathValidator(info, oldInfo, requester), validation.OrgAttributePathValidator(info, oldInfo, requester),
validation.OrgMappingValidator(info, oldInfo, requester)) validation.OrgMappingValidator(info, oldInfo, requester),
validation.LoginPromptValidator,
)
} }

View File

@ -99,6 +99,7 @@ type OAuthInfo struct {
TokenUrl string `mapstructure:"token_url" toml:"token_url"` TokenUrl string `mapstructure:"token_url" toml:"token_url"`
UsePKCE bool `mapstructure:"use_pkce" toml:"use_pkce"` UsePKCE bool `mapstructure:"use_pkce" toml:"use_pkce"`
UseRefreshToken bool `mapstructure:"use_refresh_token" toml:"use_refresh_token"` UseRefreshToken bool `mapstructure:"use_refresh_token" toml:"use_refresh_token"`
LoginPrompt string `mapstructure:"login_prompt" toml:"login_prompt"`
Extra map[string]string `mapstructure:",remain" toml:"extra,omitempty"` Extra map[string]string `mapstructure:",remain" toml:"extra,omitempty"`
} }

View File

@ -108,6 +108,7 @@ func (s *OAuthStrategy) loadSettingsForProvider(provider string) map[string]any
"signout_redirect_url": section.Key("signout_redirect_url").Value(), "signout_redirect_url": section.Key("signout_redirect_url").Value(),
"org_mapping": section.Key("org_mapping").Value(), "org_mapping": section.Key("org_mapping").Value(),
"org_attribute_path": section.Key("org_attribute_path").Value(), "org_attribute_path": section.Key("org_attribute_path").Value(),
"login_prompt": section.Key("login_prompt").Value(),
} }
extraKeys := extraKeysByProvider[provider] extraKeys := extraKeysByProvider[provider]

View File

@ -58,6 +58,7 @@ var (
signout_redirect_url = test_signout_redirect_url signout_redirect_url = test_signout_redirect_url
org_attribute_path = groups org_attribute_path = groups
org_mapping = Group1:*:Editor org_mapping = Group1:*:Editor
login_prompt = select_account
` `
expectedOAuthInfo = map[string]any{ expectedOAuthInfo = map[string]any{
@ -104,6 +105,7 @@ var (
"team_ids": "first, second", "team_ids": "first, second",
"org_attribute_path": "groups", "org_attribute_path": "groups",
"org_mapping": "Group1:*:Editor", "org_mapping": "Group1:*:Editor",
"login_prompt": "select_account",
} }
) )

View File

@ -51,6 +51,15 @@ func SkipOrgRoleSyncAllowAssignGrafanaAdminValidator(info *social.OAuthInfo, req
return nil return nil
} }
func LoginPromptValidator(info *social.OAuthInfo, requester identity.Requester) error {
prompt := info.LoginPrompt
if prompt != "" && prompt != "login" && prompt != "consent" && prompt != "select_account" {
return ssosettings.ErrInvalidOAuthConfig("Invalid value for login_prompt. Valid values are: login, consent, select_account.")
}
return nil
}
func RequiredValidator(value string, name string) ssosettings.ValidateFunc[social.OAuthInfo] { func RequiredValidator(value string, name string) ssosettings.ValidateFunc[social.OAuthInfo] {
return func(info *social.OAuthInfo, requester identity.Requester) error { return func(info *social.OAuthInfo, requester identity.Requester) error {
if value == "" { if value == "" {

View File

@ -154,6 +154,7 @@ describe('ProviderConfigForm', () => {
clientId: 'test-client-id', clientId: 'test-client-id',
clientSecret: 'test-client-secret', clientSecret: 'test-client-secret',
enabled: true, enabled: true,
loginPrompt: '',
name: 'GitHub', name: 'GitHub',
orgMapping: '["Group A:1:Editor","Group B:2:Admin"]', orgMapping: '["Group A:1:Editor","Group B:2:Admin"]',
roleAttributePath: 'new-attribute-path', roleAttributePath: 'new-attribute-path',
@ -204,6 +205,7 @@ describe('ProviderConfigForm', () => {
clientId: 'test-client-id', clientId: 'test-client-id',
clientSecret: 'test-client-secret', clientSecret: 'test-client-secret',
enabled: false, enabled: false,
loginPrompt: '',
name: 'GitHub', name: 'GitHub',
roleAttributePath: '', roleAttributePath: '',
roleAttributeStrict: false, roleAttributeStrict: false,

View File

@ -44,6 +44,7 @@ export const getSectionFields = (): Section => {
'allowSignUp', 'allowSignUp',
'autoLogin', 'autoLogin',
'signoutRedirectUrl', 'signoutRedirectUrl',
'loginPrompt',
], ],
}, },
{ {
@ -87,6 +88,7 @@ export const getSectionFields = (): Section => {
'allowSignUp', 'allowSignUp',
'autoLogin', 'autoLogin',
'signoutRedirectUrl', 'signoutRedirectUrl',
'loginPrompt',
], ],
}, },
{ {
@ -132,7 +134,16 @@ export const getSectionFields = (): Section => {
{ {
name: generalSettingsLabel, name: generalSettingsLabel,
id: 'general', id: 'general',
fields: ['name', 'clientId', 'clientSecret', 'scopes', 'allowSignUp', 'autoLogin', 'signoutRedirectUrl'], fields: [
'name',
'clientId',
'clientSecret',
'scopes',
'allowSignUp',
'autoLogin',
'signoutRedirectUrl',
'loginPrompt',
],
}, },
{ {
name: userMappingLabel, name: userMappingLabel,
@ -166,7 +177,16 @@ export const getSectionFields = (): Section => {
{ {
name: generalSettingsLabel, name: generalSettingsLabel,
id: 'general', id: 'general',
fields: ['name', 'clientId', 'clientSecret', 'scopes', 'allowSignUp', 'autoLogin', 'signoutRedirectUrl'], fields: [
'name',
'clientId',
'clientSecret',
'scopes',
'allowSignUp',
'autoLogin',
'signoutRedirectUrl',
'loginPrompt',
],
}, },
{ {
name: userMappingLabel, name: userMappingLabel,
@ -199,7 +219,16 @@ export const getSectionFields = (): Section => {
{ {
name: generalSettingsLabel, name: generalSettingsLabel,
id: 'general', id: 'general',
fields: ['name', 'clientId', 'clientSecret', 'scopes', 'allowSignUp', 'autoLogin', 'signoutRedirectUrl'], fields: [
'name',
'clientId',
'clientSecret',
'scopes',
'allowSignUp',
'autoLogin',
'signoutRedirectUrl',
'loginPrompt',
],
}, },
{ {
name: userMappingLabel, name: userMappingLabel,
@ -890,6 +919,22 @@ export function fieldMap(provider: string): Record<string, FieldData> {
message: t('auth-config.fields.domain-hint-valid-domain', 'This field must be a valid domain.'), message: t('auth-config.fields.domain-hint-valid-domain', 'This field must be a valid domain.'),
}, },
}, },
loginPrompt: {
label: t('auth-config.fields.login-prompt-label', 'Login prompt'),
type: 'select',
description: t(
'auth-config.fields.login-prompt-description',
'Indicates the type of user interaction when the user logs in with the IdP.'
),
multi: false,
options: [
{ value: '', label: '' },
{ value: 'login', label: t('auth-config.fields.login-prompt-login', 'Login') },
{ value: 'consent', label: t('auth-config.fields.login-prompt-consent', 'Consent') },
{ value: 'select_account', label: t('auth-config.fields.login-prompt-select-account', 'Select account') },
],
defaultValue: { value: '', label: '' },
},
}; };
} }

View File

@ -61,6 +61,7 @@ export type SSOProviderSettingsBase = {
// For Azure AD // For Azure AD
forceUseGraphApi?: boolean; forceUseGraphApi?: boolean;
domainHint?: string; domainHint?: string;
loginPrompt?: string;
// For Google // For Google
validateHd?: boolean; validateHd?: boolean;
}; };

View File

@ -25,6 +25,7 @@ export const emptySettings: SSOProviderDTO = {
emailAttributePath: '', emailAttributePath: '',
emptyScopes: false, emptyScopes: false,
enabled: false, enabled: false,
loginPrompt: '',
extra: {}, extra: {},
groupsAttributePath: '', groupsAttributePath: '',
hostedDomain: '', hostedDomain: '',

View File

@ -3256,6 +3256,11 @@
"id-token-attribute-name-label": "ID token attribute name", "id-token-attribute-name-label": "ID token attribute name",
"login-attribute-path-description": "JMESPath expression to use for user login lookup from the user ID token.", "login-attribute-path-description": "JMESPath expression to use for user login lookup from the user ID token.",
"login-attribute-path-label": "Login attribute path", "login-attribute-path-label": "Login attribute path",
"login-prompt-consent": "Consent",
"login-prompt-description": "Indicates the type of user interaction when the user logs in with the IdP.",
"login-prompt-label": "Login prompt",
"login-prompt-login": "Login",
"login-prompt-select-account": "Select account",
"managed-identity-client-id-description": "The managed identity client ID of the federated identity credential of your OAuth2 app.", "managed-identity-client-id-description": "The managed identity client ID of the federated identity credential of your OAuth2 app.",
"managed-identity-client-id-label": "FIC managed identity client ID", "managed-identity-client-id-label": "FIC managed identity client ID",
"name-attribute-path-description": "JMESPath expression to use for user name lookup from the user ID token. \nThis name will be used as the user's display name.", "name-attribute-path-description": "JMESPath expression to use for user name lookup from the user ID token. \nThis name will be used as the user's display name.",