Auth: Make domain_hint configurable for the Azure AD/Entra ID connector (#108061)

* Make domain_hint configurable for Entra ID/Azure AD

* Add docs

* Fix + i18n gen

* Add validation to domain hint

* Remove unnecessary change
This commit is contained in:
Misi
2025-07-15 12:53:19 +02:00
committed by GitHub
parent 4954822cbb
commit 92d098fdfd
9 changed files with 185 additions and 11 deletions
@@ -552,11 +552,13 @@ The following table outlines the various Azure AD/Entra ID configuration options
| `allowed_groups` | No | Yes | List of comma- or space-separated groups. The user should be a member of at least one group to log in. If you configure `allowed_groups`, you must also configure Azure AD/Entra ID to include the `groups` claim following [Configure group membership claims on the Azure Portal](#configure-group-membership-claims-on-the-azure-portal). | |
| `allowed_organizations` | No | Yes | List of comma- or space-separated Azure tenant identifiers. The user should be a member of at least one tenant to log in. | |
| `allowed_domains` | No | Yes | List of comma- or space-separated domains. The user should belong to at least one domain to log in. | |
| `tls_skip_verify_insecure` | No | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` |
| `tls_client_cert` | No | No | The path to the certificate. | |
| `tls_client_key` | No | No | The path to the key. | |
| `tls_client_ca` | No | No | The path to the trusted certificate authority list. | |
| `use_pkce` | No | Yes | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `true` |
| `use_refresh_token` | No | Yes | Enables the use of refresh tokens and checks for access token expiration. When enabled, Grafana automatically adds the `offline_access` scope to the list of scopes. | `true` |
| `force_use_graph_api` | No | Yes | Set to `true` to always fetch groups from the Microsoft Graph API instead of the `id_token`. If a user belongs to more than 200 groups, the Microsoft Graph API will be used to retrieve the groups regardless of this setting. | `false` |
| `signout_redirect_url` | No | Yes | URL to redirect to after the user logs out. | |
| `domain_hint` | No | Yes | The realm of the user in a federated directory. This skips the email-based discovery process that the user goes through on the Azure AD/Entra ID sign-in page, for a slightly more streamlined user experience. More info [here](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#send-the-sign-in-request). | |
| `tls_skip_verify_insecure` | No | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` |
| `tls_client_cert` | No | No | The path to the certificate. | |
| `tls_client_key` | No | No | The path to the key. | |
| `tls_client_ca` | No | No | The path to the trusted certificate authority list. | |
| `use_pkce` | No | Yes | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `true` |
| `use_refresh_token` | No | Yes | Enables the use of refresh tokens and checks for access token expiration. When enabled, Grafana automatically adds the `offline_access` scope to the list of scopes. | `true` |
| `force_use_graph_api` | No | Yes | Set to `true` to always fetch groups from the Microsoft Graph API instead of the `id_token`. If a user belongs to more than 200 groups, the Microsoft Graph API will be used to retrieve the groups regardless of this setting. | `false` |
| `signout_redirect_url` | No | Yes | URL to redirect to after the user logs out. | |
+18 -2
View File
@@ -29,12 +29,16 @@ import (
"github.com/grafana/grafana/pkg/util"
)
const forceUseGraphAPIKey = "force_use_graph_api" // #nosec G101 not a hardcoded credential
const (
forceUseGraphAPIKey = "force_use_graph_api" // #nosec G101 not a hardcoded credential
domainHintKey = "domain_hint"
)
var (
ExtraAzureADSettingKeys = map[string]ExtraKeyInfo{
forceUseGraphAPIKey: {Type: Bool, DefaultValue: false},
allowedOrganizationsKey: {Type: String},
domainHintKey: {Type: String},
}
errAzureADMissingGroups = &SocialError{"either the user does not have any group membership or the groups claim is missing from the token."}
)
@@ -288,7 +292,8 @@ func (s *SocialAzureAD) Validate(ctx context.Context, newSettings ssoModels.SSOS
validateAllowedGroups,
validation.MustBeEmptyValidator(info.ApiUrl, "API URL"),
validation.RequiredUrlValidator(info.AuthUrl, "Auth URL"),
validation.RequiredUrlValidator(info.TokenUrl, "Token URL"))
validation.RequiredUrlValidator(info.TokenUrl, "Token URL"),
validation.DomainValidator(info.Extra[domainHintKey], "Domain Hint"))
}
func validateAllowedGroups(info *social.OAuthInfo, requester identity.Requester) error {
@@ -323,6 +328,17 @@ func (s *SocialAzureAD) validateClaims(ctx context.Context, client *http.Client,
return claims, nil
}
func (s *SocialAzureAD) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
s.reloadMutex.RLock()
defer s.reloadMutex.RUnlock()
if domainHint, ok := s.info.Extra[domainHintKey]; ok && domainHint != "" {
opts = append(opts, oauth2.SetAuthURLParam("domain_hint", domainHint))
}
return s.Config.AuthCodeURL(state, opts...)
}
func (s *SocialAzureAD) validateIDTokenSignature(ctx context.Context, client *http.Client, parsedToken *jwt.JSONWebToken) (*azureClaims, error) {
var claims azureClaims
@@ -128,6 +128,7 @@ func TestGetProviderConfig_ExtraFields(t *testing.T) {
force_use_graph_api = true
allowed_organizations = org1, org2
workload_identity_token_file = azuread_token_file
domain_hint = my-domain
[auth.github]
team_ids = first, second
@@ -163,6 +164,7 @@ func TestGetProviderConfig_ExtraFields(t *testing.T) {
require.Equal(t, true, result["force_use_graph_api"])
require.Equal(t, "org1, org2", result["allowed_organizations"])
require.Equal(t, "azuread_token_file", result["workload_identity_token_file"])
require.Equal(t, "my-domain", result["domain_hint"])
})
t.Run(social.GitHubProviderName, func(t *testing.T) {
@@ -3,6 +3,7 @@ package validation
import (
"fmt"
"net/url"
"regexp"
"slices"
"strings"
@@ -11,6 +12,8 @@ import (
"github.com/grafana/grafana/pkg/services/ssosettings"
)
var domainRegexp = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,6}$`)
func AllowAssignGrafanaAdminValidator(info *social.OAuthInfo, oldInfo *social.OAuthInfo, requester identity.Requester) ssosettings.ValidateFunc[social.OAuthInfo] {
return func(info *social.OAuthInfo, requester identity.Requester) error {
hasChanged := info.AllowAssignGrafanaAdmin != oldInfo.AllowAssignGrafanaAdmin
@@ -66,6 +69,18 @@ func UrlValidator(value string, name string) ssosettings.ValidateFunc[social.OAu
}
}
func DomainValidator(value string, name string) ssosettings.ValidateFunc[social.OAuthInfo] {
return func(info *social.OAuthInfo, requester identity.Requester) error {
if value == "" {
return nil
}
if !domainRegexp.MatchString(value) {
return ssosettings.ErrInvalidOAuthConfig(fmt.Sprintf("%s contains an invalid domain.", name))
}
return nil
}
}
func RequiredUrlValidator(value string, name string) ssosettings.ValidateFunc[social.OAuthInfo] {
return func(info *social.OAuthInfo, requester identity.Requester) error {
if err := RequiredValidator(value, name)(info, requester); err != nil {
@@ -49,6 +49,113 @@ func TestUrlValidator(t *testing.T) {
}
}
func TestDomainValidator(t *testing.T) {
tc := []testCase{
{
name: "passes when domain is valid",
input: &social.OAuthInfo{
Extra: map[string]string{"domain_hint": "example.com"},
},
wantErr: nil,
},
{
name: "passes when domain is empty",
input: &social.OAuthInfo{
Extra: map[string]string{"domain_hint": ""},
},
wantErr: nil,
},
{
name: "passes when domain has subdomain",
input: &social.OAuthInfo{
Extra: map[string]string{"domain_hint": "sub.example.com"},
},
wantErr: nil,
},
{
name: "fails when domain is invalid",
input: &social.OAuthInfo{
Extra: map[string]string{"domain_hint": "invalid-domain"},
},
wantErr: ssosettings.ErrInvalidOAuthConfig("Domain Hint contains an invalid domain."),
},
{
name: "fails when domain has invalid characters",
input: &social.OAuthInfo{
Extra: map[string]string{"domain_hint": "example.com!"},
},
wantErr: ssosettings.ErrInvalidOAuthConfig("Domain Hint contains an invalid domain."),
},
{
name: "fails when TLD is too short (1 character)",
input: &social.OAuthInfo{
Extra: map[string]string{"domain_hint": "example.x"},
},
wantErr: ssosettings.ErrInvalidOAuthConfig("Domain Hint contains an invalid domain."),
},
{
name: "passes when TLD is minimum length (2 characters)",
input: &social.OAuthInfo{
Extra: map[string]string{"domain_hint": "example.co"},
},
wantErr: nil,
},
{
name: "passes when TLD is maximum length (6 characters)",
input: &social.OAuthInfo{
Extra: map[string]string{"domain_hint": "example.museum"},
},
wantErr: nil,
},
{
name: "fails when TLD is too long (7+ characters)",
input: &social.OAuthInfo{
Extra: map[string]string{"domain_hint": "example.toolong"},
},
wantErr: ssosettings.ErrInvalidOAuthConfig("Domain Hint contains an invalid domain."),
},
{
name: "passes when domain is maximum reasonable length",
input: &social.OAuthInfo{
Extra: map[string]string{"domain_hint": "very-long-subdomain-name-that-is-still-valid.example-organization.museum"},
},
wantErr: nil,
},
{
name: "fails when domain segment is too long (over 63 characters)",
input: &social.OAuthInfo{
Extra: map[string]string{"domain_hint": "this-is-a-very-long-subdomain-name-that-exceeds-the-maximum-allowed-length-for-a-dns-label.example.com"},
},
wantErr: ssosettings.ErrInvalidOAuthConfig("Domain Hint contains an invalid domain."),
},
{
name: "passes when domain segment is exactly 63 characters",
input: &social.OAuthInfo{
Extra: map[string]string{"domain_hint": "this-is-exactly-sixty-three-characters-long-which-is-the-max.com"},
},
wantErr: nil,
},
{
name: "fails when domain is just a single character",
input: &social.OAuthInfo{
Extra: map[string]string{"domain_hint": "a"},
},
wantErr: ssosettings.ErrInvalidOAuthConfig("Domain Hint contains an invalid domain."),
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
err := DomainValidator(tt.input.Extra["domain_hint"], "Domain Hint")(tt.input, tt.requester)
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
return
}
require.NoError(t, err)
})
}
}
func TestRequiredValidator(t *testing.T) {
tc := []testCase{
{
+19 -1
View File
@@ -9,7 +9,7 @@ import { contextSrv } from 'app/core/core';
import { ServerDiscoveryField } from './components/ServerDiscoveryField';
import { FieldData, SSOProvider, SSOSettingsField } from './types';
import { isSelectableValue, isSelectableValueArray } from './utils/guards';
import { isUrlValid } from './utils/url';
import { isUrlValid, isValidDomain } from './utils/url';
type Section = Record<
SSOProvider['provider'],
@@ -59,6 +59,7 @@ export const getSectionFields = (): Section => {
'allowedDomains',
'allowedGroups',
'forceUseGraphApi',
'domainHint',
'usePkce',
'useRefreshToken',
'tlsSkipVerifyInsecure',
@@ -872,6 +873,23 @@ export function fieldMap(provider: string): Record<string, FieldData> {
type: 'custom',
content: (setValue) => <ServerDiscoveryField setValue={setValue} />,
},
domainHint: {
label: t('auth-config.fields.domain-hint-label', 'Domain hint'),
description: t(
'auth-config.fields.domain-hint-description',
'Parameter to indicate the realm of the user in the Azure AD/Entra ID tenant and streamline the login process.'
),
type: 'text',
validation: {
validate: (value) => {
if (typeof value === 'string' && value.length) {
return isValidDomain(value);
}
return true;
},
message: t('auth-config.fields.domain-hint-valid-domain', 'This field must be a valid domain.'),
},
},
};
}
+1
View File
@@ -60,6 +60,7 @@ export type SSOProviderSettingsBase = {
tlsSkipVerifyInsecure?: boolean;
// For Azure AD
forceUseGraphApi?: boolean;
domainHint?: string;
// For Google
validateHd?: boolean;
};
@@ -16,3 +16,13 @@ export const isUrlValid = (url: unknown): boolean => {
return false;
}
};
export const isValidDomain = (domain: string): boolean => {
if (typeof domain !== 'string' || !domain.length) {
return false;
}
const domainRegex =
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,6}$/;
return domainRegex.test(domain);
};
+3
View File
@@ -3228,6 +3228,9 @@
"define-allowed-teams-ids-label": "Define allowed teams IDs",
"display-name-description": "Will be displayed on the login page as \"Sign in with ...\". Helpful if you use more than one identity providers or SSO protocols.",
"display-name-label": "Display name",
"domain-hint-description": "Parameter to indicate the realm of the user in the Azure AD/Entra ID tenant and streamline the login process.",
"domain-hint-label": "Domain hint",
"domain-hint-valid-domain": "This field must be a valid domain.",
"email-attribute-name-description": "Name of the key to use for user email lookup within the attributes map of OAuth2 ID token.",
"email-attribute-name-label": "Email attribute name",
"email-attribute-path-description": "JMESPath expression to use for user email lookup from the user information.",