From 92d098fdfd8b8fc557a692aa993ce2449ccfd233 Mon Sep 17 00:00:00 2001 From: Misi Date: Tue, 15 Jul 2025 12:53:19 +0200 Subject: [PATCH] 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 --- .../configure-authentication/azuread/index.md | 18 +-- pkg/login/social/connectors/azuread_oauth.go | 20 +++- .../strategies/oauth_strategy_test.go | 2 + .../validation/oauth_validators.go | 15 +++ .../validation/oauth_validators_test.go | 107 ++++++++++++++++++ public/app/features/auth-config/fields.tsx | 20 +++- public/app/features/auth-config/types.ts | 1 + public/app/features/auth-config/utils/url.ts | 10 ++ public/locales/en-US/grafana.json | 3 + 9 files changed, 185 insertions(+), 11 deletions(-) diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md index e43a45b88d1..5d5b5489061 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md @@ -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. | | diff --git a/pkg/login/social/connectors/azuread_oauth.go b/pkg/login/social/connectors/azuread_oauth.go index f06373df124..774cdb540ad 100644 --- a/pkg/login/social/connectors/azuread_oauth.go +++ b/pkg/login/social/connectors/azuread_oauth.go @@ -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 diff --git a/pkg/services/ssosettings/strategies/oauth_strategy_test.go b/pkg/services/ssosettings/strategies/oauth_strategy_test.go index c76aba9c839..568e94f6439 100644 --- a/pkg/services/ssosettings/strategies/oauth_strategy_test.go +++ b/pkg/services/ssosettings/strategies/oauth_strategy_test.go @@ -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) { diff --git a/pkg/services/ssosettings/validation/oauth_validators.go b/pkg/services/ssosettings/validation/oauth_validators.go index 7899bce8706..1ef1d2434ed 100644 --- a/pkg/services/ssosettings/validation/oauth_validators.go +++ b/pkg/services/ssosettings/validation/oauth_validators.go @@ -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 { diff --git a/pkg/services/ssosettings/validation/oauth_validators_test.go b/pkg/services/ssosettings/validation/oauth_validators_test.go index 9bb17f3eb79..09c865821fe 100644 --- a/pkg/services/ssosettings/validation/oauth_validators_test.go +++ b/pkg/services/ssosettings/validation/oauth_validators_test.go @@ -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{ { diff --git a/public/app/features/auth-config/fields.tsx b/public/app/features/auth-config/fields.tsx index 6b0edf29549..ce38363d3d9 100644 --- a/public/app/features/auth-config/fields.tsx +++ b/public/app/features/auth-config/fields.tsx @@ -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 { type: 'custom', content: (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.'), + }, + }, }; } diff --git a/public/app/features/auth-config/types.ts b/public/app/features/auth-config/types.ts index 6b6fd5c5a4e..933642c82d5 100644 --- a/public/app/features/auth-config/types.ts +++ b/public/app/features/auth-config/types.ts @@ -60,6 +60,7 @@ export type SSOProviderSettingsBase = { tlsSkipVerifyInsecure?: boolean; // For Azure AD forceUseGraphApi?: boolean; + domainHint?: string; // For Google validateHd?: boolean; }; diff --git a/public/app/features/auth-config/utils/url.ts b/public/app/features/auth-config/utils/url.ts index cf98cf311c2..57371141ac4 100644 --- a/public/app/features/auth-config/utils/url.ts +++ b/public/app/features/auth-config/utils/url.ts @@ -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); +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e94393e9580..bce4bc8109f 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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.",