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:
+10
-8
@@ -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. | |
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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.'),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user