The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/pkg/login/social/connectors/generic_oauth.go

620 lines
18 KiB

package connectors
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/mail"
"strconv"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/validation"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
const (
nameAttributePathKey = "name_attribute_path"
loginAttributePathKey = "login_attribute_path"
idTokenAttributeNameKey = "id_token_attribute_name" // #nosec G101 not a hardcoded credential
)
var ExtraGenericOAuthSettingKeys = []string{nameAttributePathKey, loginAttributePathKey, idTokenAttributeNameKey, teamIdsKey, allowedOrganizationsKey}
var _ social.SocialConnector = (*SocialGenericOAuth)(nil)
var _ ssosettings.Reloadable = (*SocialGenericOAuth)(nil)
type SocialGenericOAuth struct {
*SocialBase
allowedOrganizations []string
teamsUrl string
emailAttributeName string
emailAttributePath string
loginAttributePath string
nameAttributePath string
groupsAttributePath string
idTokenAttributeName string
teamIdsAttributePath string
teamIds []string
}
func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGenericOAuth {
provider := &SocialGenericOAuth{
SocialBase: newSocialBase(social.GenericOAuthProviderName, info, features, cfg),
teamsUrl: info.TeamsUrl,
emailAttributeName: info.EmailAttributeName,
emailAttributePath: info.EmailAttributePath,
nameAttributePath: info.Extra[nameAttributePathKey],
groupsAttributePath: info.GroupsAttributePath,
loginAttributePath: info.Extra[loginAttributePathKey],
idTokenAttributeName: info.Extra[idTokenAttributeNameKey],
teamIdsAttributePath: info.TeamIdsAttributePath,
teamIds: util.SplitString(info.Extra[teamIdsKey]),
allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]),
}
if features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) {
ssoSettings.RegisterReloadable(social.GenericOAuthProviderName, provider)
}
return provider
}
func (s *SocialGenericOAuth) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error {
info, err := CreateOAuthInfoFromKeyValues(settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
err = validateInfo(info, requester)
if err != nil {
return err
}
err = validation.Validate(info, requester,
validation.UrlValidator(info.AuthUrl, "Auth URL"),
validation.UrlValidator(info.TokenUrl, "Token URL"),
validateTeamsUrlWhenNotEmpty)
if err != nil {
return err
}
if info.Extra[teamIdsKey] != "" && (info.TeamIdsAttributePath == "" || info.TeamsUrl == "") {
return ssosettings.ErrInvalidOAuthConfig("If Team Ids are configured then Team Ids attribute path and Teams URL must be configured.")
}
if len(info.AllowedGroups) > 0 && info.GroupsAttributePath == "" {
return ssosettings.ErrInvalidOAuthConfig("If Allowed groups is configured then Groups attribute path must be configured.")
}
return nil
}
func validateTeamsUrlWhenNotEmpty(info *social.OAuthInfo, requester identity.Requester) error {
if info.TeamsUrl == "" {
return nil
}
return validation.UrlValidator(info.TeamsUrl, "Teams URL")(info, requester)
}
func (s *SocialGenericOAuth) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
s.reloadMutex.Lock()
defer s.reloadMutex.Unlock()
s.SocialBase = newSocialBase(social.GenericOAuthProviderName, newInfo, s.features, s.cfg)
s.teamsUrl = newInfo.TeamsUrl
s.emailAttributeName = newInfo.EmailAttributeName
s.emailAttributePath = newInfo.EmailAttributePath
s.nameAttributePath = newInfo.Extra[nameAttributePathKey]
s.groupsAttributePath = newInfo.GroupsAttributePath
s.loginAttributePath = newInfo.Extra[loginAttributePathKey]
s.idTokenAttributeName = newInfo.Extra[idTokenAttributeNameKey]
s.teamIdsAttributePath = newInfo.TeamIdsAttributePath
s.teamIds = util.SplitString(newInfo.Extra[teamIdsKey])
s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey])
return nil
}
// TODOD: remove this in the next PR and use the isGroupMember from social.go
func (s *SocialGenericOAuth) IsGroupMember(groups []string) bool {
info := s.GetOAuthInfo()
if len(info.AllowedGroups) == 0 {
return true
}
for _, allowedGroup := range info.AllowedGroups {
for _, group := range groups {
if group == allowedGroup {
return true
}
}
}
return false
}
func (s *SocialGenericOAuth) IsTeamMember(ctx context.Context, client *http.Client) bool {
if len(s.teamIds) == 0 {
return true
}
teamMemberships, err := s.FetchTeamMemberships(ctx, client)
if err != nil {
return false
}
for _, teamId := range s.teamIds {
for _, membershipId := range teamMemberships {
if teamId == membershipId {
return true
}
}
}
return false
}
func (s *SocialGenericOAuth) IsOrganizationMember(ctx context.Context, client *http.Client) bool {
if len(s.allowedOrganizations) == 0 {
return true
}
organizations, ok := s.FetchOrganizations(ctx, client)
if !ok {
return false
}
for _, allowedOrganization := range s.allowedOrganizations {
for _, organization := range organizations {
if organization == allowedOrganization {
return true
}
}
}
return false
}
type UserInfoJson struct {
Sub string `json:"sub"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Login string `json:"login"`
Username string `json:"username"`
Email string `json:"email"`
Upn string `json:"upn"`
Attributes map[string][]string `json:"attributes"`
rawJSON []byte
source string
}
func (info *UserInfoJson) String() string {
return fmt.Sprintf(
"Name: %s, Displayname: %s, Login: %s, Username: %s, Email: %s, Upn: %s, Attributes: %v",
info.Name, info.DisplayName, info.Login, info.Username, info.Email, info.Upn, info.Attributes)
}
func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*social.BasicUserInfo, error) {
s.log.Debug("Getting user info")
toCheck := make([]*UserInfoJson, 0, 2)
if tokenData := s.extractFromToken(token); tokenData != nil {
toCheck = append(toCheck, tokenData)
}
if apiData := s.extractFromAPI(ctx, client); apiData != nil {
toCheck = append(toCheck, apiData)
}
info := s.GetOAuthInfo()
userInfo := &social.BasicUserInfo{}
for _, data := range toCheck {
s.log.Debug("Processing external user info", "source", data.source, "data", data)
if userInfo.Id == "" {
userInfo.Id = data.Sub
}
if userInfo.Name == "" {
userInfo.Name = s.extractUserName(data)
}
if userInfo.Login == "" {
userInfo.Login = s.extractLogin(data)
}
if userInfo.Email == "" {
userInfo.Email = s.extractEmail(data)
if userInfo.Email != "" {
s.log.Debug("Set user info email from extracted email", "email", userInfo.Email)
}
}
if userInfo.Role == "" && !info.SkipOrgRoleSync {
role, grafanaAdmin, err := s.extractRoleAndAdminOptional(data.rawJSON, []string{})
if err != nil {
s.log.Warn("Failed to extract role", "err", err)
} else {
userInfo.Role = role
if info.AllowAssignGrafanaAdmin {
userInfo.IsGrafanaAdmin = &grafanaAdmin
}
}
}
if len(userInfo.Groups) == 0 {
groups, err := s.extractGroups(data)
if err != nil {
s.log.Warn("Failed to extract groups", "err", err)
} else if len(groups) > 0 {
s.log.Debug("Setting user info groups from extracted groups")
userInfo.Groups = groups
}
}
}
if userInfo.Role == "" && !info.SkipOrgRoleSync {
if info.RoleAttributeStrict {
return nil, errRoleAttributeStrictViolation.Errorf("idP did not return a role attribute")
}
userInfo.Role = s.defaultRole()
}
if info.AllowAssignGrafanaAdmin && info.SkipOrgRoleSync {
s.log.Debug("AllowAssignGrafanaAdmin and skipOrgRoleSync are both set, Grafana Admin role will not be synced, consider setting one or the other")
}
if userInfo.Email == "" {
var err error
userInfo.Email, err = s.FetchPrivateEmail(ctx, client)
if err != nil {
return nil, err
}
s.log.Debug("Setting email from fetched private email", "email", userInfo.Email)
}
if userInfo.Login == "" {
s.log.Debug("Defaulting to using email for user info login", "email", userInfo.Email)
userInfo.Login = userInfo.Email
}
if !s.IsTeamMember(ctx, client) {
return nil, errors.New("user not a member of one of the required teams")
}
if !s.IsOrganizationMember(ctx, client) {
return nil, errors.New("user not a member of one of the required organizations")
}
if !s.IsGroupMember(userInfo.Groups) {
return nil, errMissingGroupMembership
}
s.log.Debug("User info result", "result", userInfo)
return userInfo, nil
}
func (s *SocialGenericOAuth) extractFromToken(token *oauth2.Token) *UserInfoJson {
s.log.Debug("Extracting user info from OAuth token")
idTokenAttribute := "id_token"
if s.idTokenAttributeName != "" {
idTokenAttribute = s.idTokenAttributeName
s.log.Debug("Using custom id_token attribute name", "attribute_name", idTokenAttribute)
}
idToken := token.Extra(idTokenAttribute)
if idToken == nil {
s.log.Debug("No id_token found", "token", token)
return nil
}
rawJSON, err := s.retrieveRawIDToken(idToken)
if err != nil {
s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", token))
return nil
}
var data UserInfoJson
if err := json.Unmarshal(rawJSON, &data); err != nil {
s.log.Error("Error decoding id_token JSON", "raw_json", string(rawJSON), "error", err)
return nil
}
data.rawJSON = rawJSON
data.source = "token"
s.log.Debug("Received id_token", "raw_json", string(data.rawJSON), "data", data.String())
return &data
}
func (s *SocialGenericOAuth) extractFromAPI(ctx context.Context, client *http.Client) *UserInfoJson {
info := s.GetOAuthInfo()
s.log.Debug("Getting user info from API")
if info.ApiUrl == "" {
s.log.Debug("No api url configured")
return nil
}
rawUserInfoResponse, err := s.httpGet(ctx, client, info.ApiUrl)
if err != nil {
s.log.Debug("Error getting user info from API", "url", info.ApiUrl, "error", err)
return nil
}
rawJSON := rawUserInfoResponse.Body
var data UserInfoJson
if err := json.Unmarshal(rawJSON, &data); err != nil {
s.log.Error("Error decoding user info response", "raw_json", rawJSON, "error", err)
return nil
}
data.rawJSON = rawJSON
data.source = "API"
s.log.Debug("Received user info response from API", "raw_json", string(rawJSON), "data", data.String())
return &data
}
func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
if data.Email != "" {
return data.Email
}
if s.emailAttributePath != "" {
email, err := util.SearchJSONForStringAttr(s.emailAttributePath, data.rawJSON)
if err != nil {
s.log.Error("Failed to search JSON for attribute", "error", err)
} else if email != "" {
return email
}
}
emails, ok := data.Attributes[s.emailAttributeName]
if ok && len(emails) != 0 {
return emails[0]
}
if data.Upn != "" {
emailAddr, emailErr := mail.ParseAddress(data.Upn)
if emailErr == nil {
return emailAddr.Address
}
s.log.Debug("Failed to parse e-mail address", "error", emailErr.Error())
}
return ""
}
func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson) string {
if data.Login != "" {
s.log.Debug("Setting user info login from login field", "login", data.Login)
return data.Login
}
if s.loginAttributePath != "" {
s.log.Debug("Searching for login among JSON", "loginAttributePath", s.loginAttributePath)
login, err := util.SearchJSONForStringAttr(s.loginAttributePath, data.rawJSON)
if err != nil {
s.log.Error("Failed to search JSON for login attribute", "error", err)
}
if login != "" {
return login
}
}
if data.Username != "" {
s.log.Debug("Setting user info login from username field", "username", data.Username)
return data.Username
}
return ""
}
func (s *SocialGenericOAuth) extractUserName(data *UserInfoJson) string {
if s.nameAttributePath != "" {
name, err := util.SearchJSONForStringAttr(s.nameAttributePath, data.rawJSON)
if err != nil {
s.log.Error("Failed to search JSON for attribute", "error", err)
} else if name != "" {
s.log.Debug("Setting user info name from nameAttributePath", "nameAttributePath", s.nameAttributePath)
return name
}
}
if data.Name != "" {
s.log.Debug("Setting user info name from name field")
return data.Name
}
if data.DisplayName != "" {
s.log.Debug("Setting user info name from display name field")
return data.DisplayName
}
s.log.Debug("Unable to find user info name")
return ""
}
func (s *SocialGenericOAuth) extractGroups(data *UserInfoJson) ([]string, error) {
if s.groupsAttributePath == "" {
return []string{}, nil
}
return util.SearchJSONForStringSliceAttr(s.groupsAttributePath, data.rawJSON)
}
func (s *SocialGenericOAuth) FetchPrivateEmail(ctx context.Context, client *http.Client) (string, error) {
type Record struct {
Email string `json:"email"`
Primary bool `json:"primary"`
IsPrimary bool `json:"is_primary"`
Verified bool `json:"verified"`
IsConfirmed bool `json:"is_confirmed"`
}
info := s.GetOAuthInfo()
response, err := s.httpGet(ctx, client, info.ApiUrl+"/emails")
if err != nil {
s.log.Error("Error getting email address", "url", info.ApiUrl+"/emails", "error", err)
return "", fmt.Errorf("%v: %w", "Error getting email address", err)
}
var records []Record
err = json.Unmarshal(response.Body, &records)
if err != nil {
var data struct {
Values []Record `json:"values"`
}
err = json.Unmarshal(response.Body, &data)
if err != nil {
s.log.Error("Error decoding email addresses response", "raw_json", string(response.Body), "error", err)
return "", fmt.Errorf("%v: %w", "Error decoding email addresses response", err)
}
records = data.Values
}
s.log.Debug("Received email addresses", "emails", records)
var email = ""
for _, record := range records {
if record.Primary || record.IsPrimary {
email = record.Email
break
}
}
s.log.Debug("Using email address", "email", email)
return email, nil
}
func (s *SocialGenericOAuth) FetchTeamMemberships(ctx context.Context, client *http.Client) ([]string, error) {
var err error
var ids []string
if s.teamsUrl == "" {
ids, err = s.fetchTeamMembershipsFromDeprecatedTeamsUrl(ctx, client)
} else {
ids, err = s.fetchTeamMembershipsFromTeamsUrl(ctx, client)
}
if err == nil {
s.log.Debug("Received team memberships", "ids", ids)
}
return ids, err
}
func (s *SocialGenericOAuth) fetchTeamMembershipsFromDeprecatedTeamsUrl(ctx context.Context, client *http.Client) ([]string, error) {
var ids []string
type Record struct {
Id int `json:"id"`
}
info := s.GetOAuthInfo()
response, err := s.httpGet(ctx, client, info.ApiUrl+"/teams")
if err != nil {
s.log.Error("Error getting team memberships", "url", info.ApiUrl+"/teams", "error", err)
return []string{}, err
}
var records []Record
err = json.Unmarshal(response.Body, &records)
if err != nil {
s.log.Error("Error decoding team memberships response", "raw_json", string(response.Body), "error", err)
return []string{}, err
}
ids = make([]string, len(records))
for i, record := range records {
ids[i] = strconv.Itoa(record.Id)
}
return ids, nil
}
func (s *SocialGenericOAuth) fetchTeamMembershipsFromTeamsUrl(ctx context.Context, client *http.Client) ([]string, error) {
if s.teamIdsAttributePath == "" {
return []string{}, nil
}
response, err := s.httpGet(ctx, client, s.teamsUrl)
if err != nil {
s.log.Error("Error getting team memberships", "url", s.teamsUrl, "error", err)
return nil, err
}
return util.SearchJSONForStringSliceAttr(s.teamIdsAttributePath, response.Body)
}
func (s *SocialGenericOAuth) FetchOrganizations(ctx context.Context, client *http.Client) ([]string, bool) {
type Record struct {
Login string `json:"login"`
}
info := s.GetOAuthInfo()
response, err := s.httpGet(ctx, client, info.ApiUrl+"/orgs")
if err != nil {
s.log.Error("Error getting organizations", "url", info.ApiUrl+"/orgs", "error", err)
return nil, false
}
var records []Record
err = json.Unmarshal(response.Body, &records)
if err != nil {
s.log.Error("Error decoding organization response", "response", string(response.Body), "error", err)
return nil, false
}
var logins = make([]string, len(records))
for i, record := range records {
logins[i] = record.Login
}
s.log.Debug("Received organizations", "logins", logins)
return logins, true
}
func (s *SocialGenericOAuth) SupportBundleContent(bf *bytes.Buffer) error {
bf.WriteString("## GenericOAuth specific configuration\n\n")
bf.WriteString("```ini\n")
bf.WriteString(fmt.Sprintf("name_attribute_path = %s\n", s.nameAttributePath))
bf.WriteString(fmt.Sprintf("login_attribute_path = %s\n", s.loginAttributePath))
bf.WriteString(fmt.Sprintf("id_token_attribute_name = %s\n", s.idTokenAttributeName))
bf.WriteString(fmt.Sprintf("team_ids_attribute_path = %s\n", s.teamIdsAttributePath))
bf.WriteString(fmt.Sprintf("team_ids = %v\n", s.teamIds))
bf.WriteString(fmt.Sprintf("allowed_organizations = %v\n", s.allowedOrganizations))
bf.WriteString("```\n\n")
return s.SocialBase.SupportBundleContent(bf)
}