diff --git a/go.mod b/go.mod index faf9f7dbfe0..8f617bd06c1 100644 --- a/go.mod +++ b/go.mod @@ -361,7 +361,7 @@ require ( github.com/mattn/go-ieproxy v0.0.3 // indirect github.com/mattn/goveralls v0.0.6 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 //@grafana/grafana-authnz-team github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/pkg/login/social/azuread_oauth.go b/pkg/login/social/azuread_oauth.go index 640552e9aae..8b395af484f 100644 --- a/pkg/login/social/azuread_oauth.go +++ b/pkg/login/social/azuread_oauth.go @@ -16,9 +16,16 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) +const azureADProviderName = "azuread" + +var _ SocialConnector = (*SocialAzureAD)(nil) + type SocialAzureAD struct { *SocialBase cache remotecache.CacheStorage @@ -57,6 +64,30 @@ type keySetJWKS struct { jose.JSONWebKeySet } +func NewAzureADProvider(settings map[string]any, cfg *setting.Cfg, features *featuremgmt.FeatureManager, cache remotecache.CacheStorage) (*SocialAzureAD, error) { + info, err := createOAuthInfoFromKeyValues(settings) + if err != nil { + return nil, err + } + + config := createOAuthConfig(info, cfg, azureADProviderName) + provider := &SocialAzureAD{ + SocialBase: newSocialBase(azureADProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + cache: cache, + allowedOrganizations: util.SplitString(info.Extra["allowed_organizations"]), + forceUseGraphAPI: mustBool(info.Extra["force_use_graph_api"], false), + skipOrgRoleSync: cfg.AzureADSkipOrgRoleSync, + // FIXME: Move skipOrgRoleSync to OAuthInfo + // skipOrgRoleSync: info.SkipOrgRoleSync + } + + if info.UseRefreshToken && features.IsEnabledGlobally(featuremgmt.FlagAccessTokenExpirationCheck) { + appendUniqueScope(config, OfflineAccessScope) + } + + return provider, nil +} + func (s *SocialAzureAD) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { idToken := token.Extra("id_token") if idToken == nil { @@ -122,6 +153,10 @@ func (s *SocialAzureAD) UserInfo(ctx context.Context, client *http.Client, token }, nil } +func (s *SocialAzureAD) GetOAuthInfo() *OAuthInfo { + return s.info +} + func (s *SocialAzureAD) validateClaims(ctx context.Context, client *http.Client, parsedToken *jwt.JSONWebToken) (*azureClaims, error) { claims, err := s.validateIDTokenSignature(ctx, client, parsedToken) if err != nil { diff --git a/pkg/login/social/azuread_oauth_test.go b/pkg/login/social/azuread_oauth_test.go index d8819629c4b..96be15224fa 100644 --- a/pkg/login/social/azuread_oauth_test.go +++ b/pkg/login/social/azuread_oauth_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" ) func trueBoolPtr() *bool { @@ -32,11 +33,9 @@ func falseBoolPtr() *bool { func TestSocialAzureAD_UserInfo(t *testing.T) { type fields struct { - SocialBase *SocialBase - allowedGroups []string - allowedOrganizations []string - forceUseGraphAPI bool - usGovURL bool + providerCfg map[string]any + cfg *setting.Cfg + usGovURL bool } type args struct { client *http.Client @@ -61,7 +60,13 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()), + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Viewer", + }, }, want: &BasicUserInfo{ Id: "1234", @@ -75,7 +80,13 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "No email", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()), + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Viewer", + }, }, claims: &azureClaims{ Email: "", @@ -88,8 +99,17 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { wantErr: true, }, { - name: "No id token", - claims: nil, + name: "No id token", + claims: nil, + fields: fields{ + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Viewer", + }, + }, want: nil, wantErr: true, }, @@ -103,8 +123,14 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()), - usGovURL: true, + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Viewer", + }, + usGovURL: true, }, want: &BasicUserInfo{ Id: "1234", @@ -125,7 +151,13 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()), + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Viewer", + }, }, want: &BasicUserInfo{ Id: "1234", @@ -139,7 +171,13 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Admin role", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()), + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Viewer", + }, }, claims: &azureClaims{ Email: "me@example.com", @@ -160,7 +198,13 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Lowercase Admin role", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()), + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Viewer", + }, }, claims: &azureClaims{ Email: "me@example.com", @@ -181,7 +225,13 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Only other roles", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()), + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Viewer", + }, }, claims: &azureClaims{ Email: "me@example.com", @@ -199,6 +249,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { Groups: []string{}, }, }, + // TODO: @mgyongyosi check this test { name: "role from env variable", claims: &azureClaims{ @@ -209,7 +260,13 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "Editor", false, *featuremgmt.WithFeatures()), + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Editor", + }, }, want: &BasicUserInfo{ Id: "1234", @@ -230,7 +287,13 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "Editor", false, *featuremgmt.WithFeatures()), + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Editor", + }, }, want: &BasicUserInfo{ Id: "1234", @@ -244,7 +307,13 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Admin and Editor roles in claim", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "Editor", false, *featuremgmt.WithFeatures()), + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Editor", + }, }, claims: &azureClaims{ Email: "me@example.com", @@ -263,8 +332,18 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { }, }, { - name: "Grafana Admin but setting is disabled", - fields: fields{SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Editor", false, *featuremgmt.WithFeatures())}, + name: "Grafana Admin but setting is disabled", + fields: fields{ + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + "allow_assign_grafana_admin": false, + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Editor", + }, + }, + claims: &azureClaims{ Email: "me@example.com", PreferredUsername: "", @@ -285,8 +364,14 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Editor roles in claim and GrafanaAdminAssignment enabled", fields: fields{ - SocialBase: newSocialBase("azuread", - &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false, *featuremgmt.WithFeatures()), + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + "allow_assign_grafana_admin": true, + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "", + }, }, claims: &azureClaims{ Email: "me@example.com", @@ -307,8 +392,16 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { }, { name: "Grafana Admin and Editor roles in claim", - fields: fields{SocialBase: newSocialBase("azuread", - &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false, *featuremgmt.WithFeatures())}, + fields: fields{ + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + "allow_assign_grafana_admin": true, + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "", + }, + }, claims: &azureClaims{ Email: "me@example.com", PreferredUsername: "", @@ -329,8 +422,15 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Error if user is not a member of allowed_groups", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Editor", false, *featuremgmt.WithFeatures()), - allowedGroups: []string{"dead-beef"}, + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + "allow_assign_grafana_admin": false, + "allowed_groups": "dead-beef", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Editor", + }, }, claims: &azureClaims{ Email: "me@example.com", @@ -346,8 +446,15 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Error if user is not a member of allowed_organizations", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Editor", false, *featuremgmt.WithFeatures()), - allowedOrganizations: []string{"uuid-1234"}, + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + "allow_assign_grafana_admin": false, + "allowed_organizations": "uuid-1234", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Editor", + }, }, claims: &azureClaims{ Email: "me@example.com", @@ -360,10 +467,18 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { }, want: nil, wantErr: true, - }, { + }, + { name: "No error if user is a member of allowed_organizations", fields: fields{ - allowedOrganizations: []string{"uuid-1234", "uuid-5678"}, + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + "allowed_organizations": "uuid-1234,uuid-5678", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Viewer", + }, }, claims: &azureClaims{ Email: "me@example.com", @@ -387,9 +502,15 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "No Error if user is a member of allowed_groups", fields: fields{ - allowedGroups: []string{"foo", "bar"}, - SocialBase: newSocialBase("azuread", - &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Viewer", false, *featuremgmt.WithFeatures()), + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + "allow_assign_grafana_admin": "false", + "allowed_groups": "foo, bar", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "Viewer", + }, }, claims: &azureClaims{ Email: "me@example.com", @@ -411,7 +532,13 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch groups when ClaimsNames and ClaimsSources is set", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures()), + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "", + }, }, claims: &azureClaims{ ID: "1", @@ -436,8 +563,14 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch groups when forceUseGraphAPI is set", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures()), - forceUseGraphAPI: true, + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + "force_use_graph_api": "true", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "", + }, }, claims: &azureClaims{ ID: "1", @@ -463,7 +596,14 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch empty role when strict attribute role is true and no match", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{RoleAttributeStrict: true}, "", false, *featuremgmt.WithFeatures()), + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + "role_attribute_strict": "true", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "", + }, }, claims: &azureClaims{ Email: "me@example.com", @@ -479,7 +619,14 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch empty role when strict attribute role is true and no role claims returned", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{RoleAttributeStrict: true}, "", false, *featuremgmt.WithFeatures()), + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + "role_attribute_strict": "true", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "", + }, }, claims: &azureClaims{ Email: "me@example.com", @@ -528,20 +675,8 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := &SocialAzureAD{ - SocialBase: tt.fields.SocialBase, - allowedOrganizations: tt.fields.allowedOrganizations, - forceUseGraphAPI: tt.fields.forceUseGraphAPI, - cache: cache, - } - - if tt.fields.SocialBase == nil { - s.SocialBase = newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures()) - } - - if tt.fields.allowedGroups != nil { - s.allowedGroups = tt.fields.allowedGroups - } + s, err := NewAzureADProvider(tt.fields.providerCfg, tt.fields.cfg, featuremgmt.WithFeatures(), cache) + require.NoError(t, err) if tt.fields.usGovURL { s.SocialBase.Endpoint.AuthURL = usGovAuthURL @@ -592,9 +727,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { token = token.WithExtra(map[string]any{"id_token": raw}) } - if tt.fields.SocialBase != nil { - tt.args.client = s.Client(context.Background(), token) - } + tt.args.client = s.Client(context.Background(), token) got, err := s.UserInfo(context.Background(), tt.args.client, token) if (err != nil) != tt.wantErr { @@ -609,20 +742,15 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { func TestSocialAzureAD_SkipOrgRole(t *testing.T) { type fields struct { - SocialBase *SocialBase - allowedGroups []string - forceUseGraphAPI bool - skipOrgRoleSync bool - } - type args struct { - client *http.Client + SocialBase *SocialBase + providerCfg map[string]any + cfg *setting.Cfg } tests := []struct { name string fields fields claims *azureClaims - args args settingAutoAssignOrgRole string want *BasicUserInfo wantErr bool @@ -630,10 +758,16 @@ func TestSocialAzureAD_SkipOrgRole(t *testing.T) { { name: "Grafana Admin and Editor roles in claim, skipOrgRoleSync disabled should get roles, skipOrgRoleSyncBase disabled", fields: fields{ - SocialBase: newSocialBase("azuread", - &oauth2.Config{ClientID: "client-id-example"}, - &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false, *featuremgmt.WithFeatures()), - skipOrgRoleSync: false, + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "false", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "", + OAuthSkipOrgRoleUpdateSync: false, + }, }, claims: &azureClaims{ Email: "me@example.com", @@ -655,10 +789,16 @@ func TestSocialAzureAD_SkipOrgRole(t *testing.T) { { name: "Grafana Admin and Editor roles in claim, skipOrgRoleSync disabled should not get roles", fields: fields{ - SocialBase: newSocialBase("azuread", - &oauth2.Config{ClientID: "client-id-example"}, - &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false, *featuremgmt.WithFeatures()), - skipOrgRoleSync: false, + providerCfg: map[string]any{ + "name": "azuread", + "client_id": "client-id-example", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "false", + }, + cfg: &setting.Cfg{ + AutoAssignOrgRole: "", + OAuthSkipOrgRoleUpdateSync: false, + }, }, claims: &azureClaims{ Email: "me@example.com", @@ -711,18 +851,8 @@ func TestSocialAzureAD_SkipOrgRole(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := &SocialAzureAD{ - SocialBase: tt.fields.SocialBase, - forceUseGraphAPI: tt.fields.forceUseGraphAPI, - skipOrgRoleSync: tt.fields.skipOrgRoleSync, - cache: cache, - } - - if tt.fields.SocialBase == nil { - s.SocialBase = newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{ - AllowedGroups: tt.fields.allowedGroups, - }, "", false, *featuremgmt.WithFeatures()) - } + s, err := NewAzureADProvider(tt.fields.providerCfg, tt.fields.cfg, featuremgmt.WithFeatures(), cache) + require.NoError(t, err) s.SocialBase.Endpoint.AuthURL = authURL @@ -769,11 +899,9 @@ func TestSocialAzureAD_SkipOrgRole(t *testing.T) { token = token.WithExtra(map[string]any{"id_token": raw}) } - if tt.fields.SocialBase != nil { - tt.args.client = s.Client(context.Background(), token) - } + provClient := s.Client(context.Background(), token) - got, err := s.UserInfo(context.Background(), tt.args.client, token) + got, err := s.UserInfo(context.Background(), provClient, token) if (err != nil) != tt.wantErr { t.Errorf("UserInfo() error = %v, wantErr %v", err, tt.wantErr) return @@ -783,3 +911,46 @@ func TestSocialAzureAD_SkipOrgRole(t *testing.T) { }) } } + +func TestSocialAzureAD_InitializeExtraFields(t *testing.T) { + type settingFields struct { + forceUseGraphAPI bool + allowedOrganizations []string + } + testCases := []struct { + name string + settings map[string]any + want settingFields + }{ + { + name: "forceUseGraphAPI is set to true", + settings: map[string]any{ + "force_use_graph_api": "true", + }, + want: settingFields{ + forceUseGraphAPI: true, + allowedOrganizations: []string{}, + }, + }, + { + name: "allowedOrganizations is set", + settings: map[string]any{ + "allowed_organizations": "uuid-1234,uuid-5678", + }, + want: settingFields{ + forceUseGraphAPI: false, + allowedOrganizations: []string{"uuid-1234", "uuid-5678"}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s, err := NewAzureADProvider(tc.settings, &setting.Cfg{}, featuremgmt.WithFeatures(), nil) + require.NoError(t, err) + + require.Equal(t, tc.want.forceUseGraphAPI, s.forceUseGraphAPI) + require.Equal(t, tc.want.allowedOrganizations, s.allowedOrganizations) + }) + } +} diff --git a/pkg/login/social/common.go b/pkg/login/social/common.go index 24ad20c0728..6a7eb3f50ff 100644 --- a/pkg/login/social/common.go +++ b/pkg/login/social/common.go @@ -7,9 +7,16 @@ import ( "fmt" "io" "net/http" + "reflect" + "strconv" "strings" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" "github.com/jmespath/go-jmespath" + "github.com/mitchellh/mapstructure" + "golang.org/x/oauth2" + "gopkg.in/ini.v1" ) var ( @@ -132,3 +139,101 @@ func (s *SocialBase) searchJSONForStringArrayAttr(attributePath string, data []b return result, nil } + +func createOAuthConfig(info *OAuthInfo, cfg *setting.Cfg, defaultName string) *oauth2.Config { + var authStyle oauth2.AuthStyle + switch strings.ToLower(info.AuthStyle) { + case "inparams": + authStyle = oauth2.AuthStyleInParams + case "inheader": + authStyle = oauth2.AuthStyleInHeader + default: + authStyle = oauth2.AuthStyleAutoDetect + } + + config := oauth2.Config{ + ClientID: info.ClientId, + ClientSecret: info.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: info.AuthUrl, + TokenURL: info.TokenUrl, + AuthStyle: authStyle, + }, + RedirectURL: strings.TrimSuffix(cfg.AppURL, "/") + SocialBaseUrl + defaultName, + Scopes: info.Scopes, + } + + return &config +} + +func mustBool(value any, defaultValue bool) bool { + if value == nil { + return defaultValue + } + + str, ok := value.(string) + if ok { + result, err := strconv.ParseBool(str) + if err != nil { + return defaultValue + } + return result + } + + result, ok := value.(bool) + if !ok { + return defaultValue + } + + return result +} + +// convertIniSectionToMap converts key value pairs from an ini section to a map[string]any +func convertIniSectionToMap(sec *ini.Section) map[string]any { + mappedSettings := make(map[string]any) + for k, v := range sec.KeysHash() { + mappedSettings[k] = v + } + return mappedSettings +} + +// createOAuthInfoFromKeyValues creates an OAuthInfo struct from a map[string]any using mapstructure +// it puts all extra key values into OAuthInfo's Extra map +func createOAuthInfoFromKeyValues(settingsKV map[string]any) (*OAuthInfo, error) { + emptyStrToSliceDecodeHook := func(from reflect.Type, to reflect.Type, data any) (any, error) { + if from.Kind() == reflect.String && to.Kind() == reflect.Slice { + strData, ok := data.(string) + if !ok { + return nil, fmt.Errorf("failed to convert %v to string", data) + } + + if strData == "" { + return []string{}, nil + } + return util.SplitString(strData), nil + } + return data, nil + } + + var oauthInfo OAuthInfo + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + DecodeHook: emptyStrToSliceDecodeHook, + Result: &oauthInfo, + WeaklyTypedInput: true, + }) + + if err != nil { + return nil, err + } + + err = decoder.Decode(settingsKV) + if err != nil { + return nil, err + } + + if oauthInfo.EmptyScopes { + oauthInfo.Scopes = []string{} + } + + return &oauthInfo, err +} diff --git a/pkg/login/social/commont_test.go b/pkg/login/social/commont_test.go new file mode 100644 index 00000000000..5a86a83cc1b --- /dev/null +++ b/pkg/login/social/commont_test.go @@ -0,0 +1,101 @@ +package social + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/ini.v1" +) + +func TestMapping_IniSectionOAuthInfo(t *testing.T) { + iniContent := ` +[test] +name = OAuth +icon = signin +enabled = true +allow_sign_up = false +auto_login = true +client_id = test_client_id +client_secret = test_client_secret +scopes = ["openid", "profile", "email"] +empty_scopes = false +email_attribute_name = email:primary +email_attribute_path = email +login_attribute_path = login +name_attribute_path = name +role_attribute_path = role +role_attribute_strict = true +groups_attribute_path = groups +id_token_attribute_name = id_token +team_ids_attribute_path = team_ids +auth_url = test_auth_url +token_url = test_token_url +api_url = test_api_url +teams_url = test_teams_url +allowed_domains = domain1.com +allowed_groups = +team_ids = first, second +allowed_organizations = org1, org2 +tls_skip_verify_insecure = true +tls_client_cert = +tls_client_key = +tls_client_ca = +use_pkce = false +auth_style = +allow_assign_grafana_admin = true +skip_org_role_sync = true +use_refresh_token = true +empty_scopes = +hosted_domain = test_hosted_domain +` + + iniFile, err := ini.Load([]byte(iniContent)) + require.NoError(t, err) + + expectedOAuthInfo := &OAuthInfo{ + Name: "OAuth", + Icon: "signin", + Enabled: true, + AllowSignup: false, + AutoLogin: true, + ClientId: "test_client_id", + ClientSecret: "test_client_secret", + Scopes: []string{"openid", "profile", "email"}, + EmptyScopes: false, + EmailAttributeName: "email:primary", + EmailAttributePath: "email", + RoleAttributePath: "role", + RoleAttributeStrict: true, + GroupsAttributePath: "groups", + TeamIdsAttributePath: "team_ids", + AuthUrl: "test_auth_url", + TokenUrl: "test_token_url", + ApiUrl: "test_api_url", + TeamsUrl: "test_teams_url", + AllowedDomains: []string{"domain1.com"}, + AllowedGroups: []string{}, + TlsSkipVerify: true, + TlsClientCert: "", + TlsClientKey: "", + TlsClientCa: "", + UsePKCE: false, + AuthStyle: "", + AllowAssignGrafanaAdmin: true, + UseRefreshToken: true, + HostedDomain: "test_hosted_domain", + Extra: map[string]string{ + "allowed_organizations": "org1, org2", + "id_token_attribute_name": "id_token", + "login_attribute_path": "login", + "name_attribute_path": "name", + "skip_org_role_sync": "true", + "team_ids": "first, second", + }, + } + + settingsKVs := convertIniSectionToMap(iniFile.Section("test")) + oauthInfo, err := createOAuthInfoFromKeyValues(settingsKVs) + require.NoError(t, err) + + require.Equal(t, expectedOAuthInfo, oauthInfo) +} diff --git a/pkg/login/social/generic_oauth.go b/pkg/login/social/generic_oauth.go index 57cab0dee57..1dc7a6c1824 100644 --- a/pkg/login/social/generic_oauth.go +++ b/pkg/login/social/generic_oauth.go @@ -11,8 +11,14 @@ import ( "strconv" "golang.org/x/oauth2" + + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) +const genericOAuthProviderName = "generic_oauth" + type SocialGenericOAuth struct { *SocialBase allowedOrganizations []string @@ -30,6 +36,36 @@ type SocialGenericOAuth struct { skipOrgRoleSync bool } +func NewGenericOAuthProvider(settings map[string]any, cfg *setting.Cfg, features *featuremgmt.FeatureManager) (*SocialGenericOAuth, error) { + info, err := createOAuthInfoFromKeyValues(settings) + if err != nil { + return nil, err + } + + config := createOAuthConfig(info, cfg, genericOAuthProviderName) + provider := &SocialGenericOAuth{ + SocialBase: newSocialBase(genericOAuthProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + apiUrl: info.ApiUrl, + teamsUrl: info.TeamsUrl, + emailAttributeName: info.EmailAttributeName, + emailAttributePath: info.EmailAttributePath, + nameAttributePath: info.Extra["name_attribute_path"], + groupsAttributePath: info.GroupsAttributePath, + loginAttributePath: info.Extra["login_attribute_path"], + idTokenAttributeName: info.Extra["id_token_attribute_name"], + teamIdsAttributePath: info.TeamIdsAttributePath, + teamIds: util.SplitString(info.Extra["team_ids"]), + allowedOrganizations: util.SplitString(info.Extra["allowed_organizations"]), + allowedGroups: info.AllowedGroups, + skipOrgRoleSync: cfg.GenericOAuthSkipOrgRoleSync, + // FIXME: Move skipOrgRoleSync to OAuthInfo + // skipOrgRoleSync: info.SkipOrgRoleSync + } + + return provider, nil +} + +// TODOD: remove this in the next PR and use the isGroupMember from social.go func (s *SocialGenericOAuth) IsGroupMember(groups []string) bool { if len(s.allowedGroups) == 0 { return true @@ -205,6 +241,10 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client, return userInfo, nil } +func (s *SocialGenericOAuth) GetOAuthInfo() *OAuthInfo { + return s.info +} + func (s *SocialGenericOAuth) extractFromToken(token *oauth2.Token) *UserInfoJson { s.log.Debug("Extracting user info from OAuth token") diff --git a/pkg/login/social/generic_oauth_test.go b/pkg/login/social/generic_oauth_test.go index 9fc448a23e6..8157636ffa8 100644 --- a/pkg/login/social/generic_oauth_test.go +++ b/pkg/login/social/generic_oauth_test.go @@ -8,28 +8,20 @@ import ( "testing" "time" - "github.com/go-kit/log/level" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" - "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" -) -func newLogger(name string, lev string) log.Logger { - logger := log.New(name) - logger.Swap(level.NewFilter(logger.GetLogger(), level.AllowInfo())) - return logger -} + "github.com/grafana/grafana/pkg/setting" +) func TestSearchJSONForEmail(t *testing.T) { t.Run("Given a generic OAuth provider", func(t *testing.T) { - provider := SocialGenericOAuth{ - SocialBase: &SocialBase{ - log: newLogger("generic_oauth_test", "debug"), - }, - } + provider, err := NewGenericOAuthProvider(map[string]any{}, &setting.Cfg{}, featuremgmt.WithFeatures()) + require.NoError(t, err) tests := []struct { Name string @@ -113,11 +105,8 @@ func TestSearchJSONForEmail(t *testing.T) { func TestSearchJSONForGroups(t *testing.T) { t.Run("Given a generic OAuth provider", func(t *testing.T) { - provider := SocialGenericOAuth{ - SocialBase: &SocialBase{ - log: newLogger("generic_oauth_test", "debug"), - }, - } + provider, err := NewGenericOAuthProvider(map[string]any{}, &setting.Cfg{}, featuremgmt.WithFeatures()) + require.NoError(t, err) tests := []struct { Name string @@ -176,11 +165,8 @@ func TestSearchJSONForGroups(t *testing.T) { func TestSearchJSONForRole(t *testing.T) { t.Run("Given a generic OAuth provider", func(t *testing.T) { - provider := SocialGenericOAuth{ - SocialBase: &SocialBase{ - log: newLogger("generic_oauth_test", "debug"), - }, - } + provider, err := NewGenericOAuthProvider(map[string]any{}, &setting.Cfg{}, featuremgmt.WithFeatures()) + require.NoError(t, err) tests := []struct { Name string @@ -238,12 +224,10 @@ func TestSearchJSONForRole(t *testing.T) { } func TestUserInfoSearchesForEmailAndRole(t *testing.T) { - provider := SocialGenericOAuth{ - SocialBase: &SocialBase{ - log: newLogger("generic_oauth_test", "debug"), - }, - emailAttributePath: "email", - } + provider, err := NewGenericOAuthProvider(map[string]any{ + "email_attribute_path": "email", + }, &setting.Cfg{}, featuremgmt.WithFeatures()) + require.NoError(t, err) tests := []struct { Name string @@ -508,12 +492,10 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) { func TestUserInfoSearchesForLogin(t *testing.T) { t.Run("Given a generic OAuth provider", func(t *testing.T) { - provider := SocialGenericOAuth{ - SocialBase: &SocialBase{ - log: newLogger("generic_oauth_test", "debug"), - }, - loginAttributePath: "login", - } + provider, err := NewGenericOAuthProvider(map[string]any{ + "login_attribute_path": "login", + }, &setting.Cfg{}, featuremgmt.WithFeatures()) + require.NoError(t, err) tests := []struct { Name string @@ -603,12 +585,10 @@ func TestUserInfoSearchesForLogin(t *testing.T) { func TestUserInfoSearchesForName(t *testing.T) { t.Run("Given a generic OAuth provider", func(t *testing.T) { - provider := SocialGenericOAuth{ - SocialBase: &SocialBase{ - log: newLogger("generic_oauth_test", "debug"), - }, - nameAttributePath: "name", - } + provider, err := NewGenericOAuthProvider(map[string]any{ + "name_attribute_path": "name", + }, &setting.Cfg{}, featuremgmt.WithFeatures()) + require.NoError(t, err) tests := []struct { Name string @@ -701,12 +681,6 @@ func TestUserInfoSearchesForName(t *testing.T) { func TestUserInfoSearchesForGroup(t *testing.T) { t.Run("Given a generic OAuth provider", func(t *testing.T) { - provider := SocialGenericOAuth{ - SocialBase: &SocialBase{ - log: newLogger("generic_oauth_test", "debug"), - }, - } - tests := []struct { name string groupsAttributePath string @@ -742,7 +716,6 @@ func TestUserInfoSearchesForGroup(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - provider.groupsAttributePath = test.groupsAttributePath body, err := json.Marshal(test.responseBody) require.NoError(t, err) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -752,7 +725,13 @@ func TestUserInfoSearchesForGroup(t *testing.T) { _, err := w.Write(body) require.NoError(t, err) })) - provider.apiUrl = ts.URL + + provider, err := NewGenericOAuthProvider(map[string]any{ + "groups_attribute_path": test.groupsAttributePath, + "api_url": ts.URL, + }, &setting.Cfg{}, featuremgmt.WithFeatures()) + require.NoError(t, err) + token := &oauth2.Token{ AccessToken: "", TokenType: "", @@ -769,12 +748,10 @@ func TestUserInfoSearchesForGroup(t *testing.T) { } func TestPayloadCompression(t *testing.T) { - provider := SocialGenericOAuth{ - SocialBase: &SocialBase{ - log: newLogger("generic_oauth_test", "debug"), - }, - emailAttributePath: "email", - } + provider, err := NewGenericOAuthProvider(map[string]any{ + "email_attribute_path": "email", + }, &setting.Cfg{}, featuremgmt.WithFeatures()) + require.NoError(t, err) tests := []struct { Name string @@ -836,3 +813,97 @@ func TestPayloadCompression(t *testing.T) { }) } } + +func TestSocialGenericOAuth_InitializeExtraFields(t *testing.T) { + type settingFields struct { + nameAttributePath string + loginAttributePath string + idTokenAttributeName string + teamIds []string + allowedOrganizations []string + } + testCases := []struct { + name string + settings map[string]any + want settingFields + }{ + { + name: "nameAttributePath is set", + settings: map[string]any{ + "name_attribute_path": "name", + }, + want: settingFields{ + nameAttributePath: "name", + loginAttributePath: "", + idTokenAttributeName: "", + teamIds: []string{}, + allowedOrganizations: []string{}, + }, + }, + { + name: "loginAttributePath is set", + settings: map[string]any{ + "login_attribute_path": "login", + }, + want: settingFields{ + nameAttributePath: "", + loginAttributePath: "login", + idTokenAttributeName: "", + teamIds: []string{}, + allowedOrganizations: []string{}, + }, + }, + { + name: "idTokenAttributeName is set", + settings: map[string]any{ + "id_token_attribute_name": "id_token", + }, + want: settingFields{ + nameAttributePath: "", + loginAttributePath: "", + idTokenAttributeName: "id_token", + teamIds: []string{}, + allowedOrganizations: []string{}, + }, + }, + { + name: "teamIds is set", + settings: map[string]any{ + "team_ids": "[\"team1\", \"team2\"]", + }, + want: settingFields{ + nameAttributePath: "", + loginAttributePath: "", + idTokenAttributeName: "", + teamIds: []string{"team1", "team2"}, + allowedOrganizations: []string{}, + }, + }, + { + name: "allowedOrganizations is set", + settings: map[string]any{ + "allowed_organizations": "org1, org2", + }, + want: settingFields{ + nameAttributePath: "", + loginAttributePath: "", + idTokenAttributeName: "", + teamIds: []string{}, + allowedOrganizations: []string{"org1", "org2"}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s, err := NewGenericOAuthProvider(tc.settings, &setting.Cfg{}, featuremgmt.WithFeatures()) + require.NoError(t, err) + + require.Equal(t, tc.want.nameAttributePath, s.nameAttributePath) + require.Equal(t, tc.want.loginAttributePath, s.loginAttributePath) + require.Equal(t, tc.want.idTokenAttributeName, s.idTokenAttributeName) + require.Equal(t, tc.want.teamIds, s.teamIds) + require.Equal(t, tc.want.allowedOrganizations, s.allowedOrganizations) + }) + } +} diff --git a/pkg/login/social/github_oauth.go b/pkg/login/social/github_oauth.go index 9c6985cf27e..4d01b8bd3de 100644 --- a/pkg/login/social/github_oauth.go +++ b/pkg/login/social/github_oauth.go @@ -7,14 +7,20 @@ import ( "fmt" "net/http" "regexp" + "strconv" "strings" "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/errutil" ) +const gitHubProviderName = "github" + type SocialGithub struct { *SocialBase allowedOrganizations []string @@ -43,6 +49,31 @@ var ( "User is not a member of one of the required organizations. Please contact identity provider administrator.")) ) +func NewGitHubProvider(settings map[string]any, cfg *setting.Cfg, features *featuremgmt.FeatureManager) (*SocialGithub, error) { + info, err := createOAuthInfoFromKeyValues(settings) + if err != nil { + return nil, err + } + + teamIds, err := mustInts(util.SplitString(info.Extra["team_ids"])) + if err != nil { + return nil, err + } + + config := createOAuthConfig(info, cfg, gitHubProviderName) + provider := &SocialGithub{ + SocialBase: newSocialBase(gitHubProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + apiUrl: info.ApiUrl, + teamIds: teamIds, + allowedOrganizations: util.SplitString(info.Extra["allowed_organizations"]), + skipOrgRoleSync: cfg.GitHubSkipOrgRoleSync, + // FIXME: Move skipOrgRoleSync to OAuthInfo + // skipOrgRoleSync: info.SkipOrgRoleSync + } + + return provider, nil +} + func (s *SocialGithub) IsTeamMember(ctx context.Context, client *http.Client) bool { if len(s.teamIds) == 0 { return true @@ -276,6 +307,10 @@ func (t *GithubTeam) GetShorthand() (string, error) { return fmt.Sprintf("@%s/%s", t.Organization.Login, t.Slug), nil } +func (s *SocialGithub) GetOAuthInfo() *OAuthInfo { + return s.info +} + func convertToGroupList(t []GithubTeam) []string { groups := make([]string, 0) for _, team := range t { @@ -291,3 +326,15 @@ func convertToGroupList(t []GithubTeam) []string { return groups } + +func mustInts(s []string) ([]int, error) { + result := make([]int, 0, len(s)) + for _, v := range s { + num, err := strconv.Atoi(v) + if err != nil { + return nil, err + } + result = append(result, num) + } + return result, nil +} diff --git a/pkg/login/social/github_oauth_test.go b/pkg/login/social/github_oauth_test.go index df8771dfd4f..d8aefe2f980 100644 --- a/pkg/login/social/github_oauth_test.go +++ b/pkg/login/social/github_oauth_test.go @@ -12,6 +12,7 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" ) const testGHUserTeamsJSON = `[ @@ -238,14 +239,16 @@ func TestSocialGitHub_UserInfo(t *testing.T) { })) defer server.Close() - s := &SocialGithub{ - SocialBase: newSocialBase("github", &oauth2.Config{}, - &OAuthInfo{RoleAttributePath: tt.roleAttributePath}, tt.autoAssignOrgRole, false, *featuremgmt.WithFeatures()), - allowedOrganizations: []string{}, - apiUrl: server.URL + "/user", - teamIds: []int{}, - skipOrgRoleSync: tt.settingSkipOrgRoleSync, - } + s, err := NewGitHubProvider(map[string]any{ + "allowed_organizations": "", + "api_url": server.URL + "/user", + "team_ids": "", + "role_attribute_path": tt.roleAttributePath, + }, &setting.Cfg{ + AutoAssignOrgRole: tt.autoAssignOrgRole, + GitHubSkipOrgRoleSync: tt.settingSkipOrgRoleSync, + }, featuremgmt.WithFeatures()) + require.NoError(t, err) token := &oauth2.Token{ AccessToken: "fake_token", @@ -262,3 +265,57 @@ func TestSocialGitHub_UserInfo(t *testing.T) { }) } } + +func TestSocialGitHub_InitializeExtraFields(t *testing.T) { + type settingFields struct { + teamIds []int + allowedOrganizations []string + } + testCases := []struct { + name string + settings map[string]any + want settingFields + }{ + { + name: "teamIds is set", + settings: map[string]any{ + "team_ids": "1234,5678", + }, + want: settingFields{ + teamIds: []int{1234, 5678}, + allowedOrganizations: []string{}, + }, + }, + { + name: "allowedOrganizations is set", + settings: map[string]any{ + "allowed_organizations": "uuid-1234,uuid-5678", + }, + want: settingFields{ + teamIds: []int{}, + allowedOrganizations: []string{"uuid-1234", "uuid-5678"}, + }, + }, + { + name: "teamIds and allowedOrganizations are empty", + settings: map[string]any{ + "team_ids": "", + "allowed_organizations": "", + }, + want: settingFields{ + teamIds: []int{}, + allowedOrganizations: []string{}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s, err := NewGitHubProvider(tc.settings, &setting.Cfg{}, featuremgmt.WithFeatures()) + require.NoError(t, err) + + require.Equal(t, tc.want.teamIds, s.teamIds) + require.Equal(t, tc.want.allowedOrganizations, s.allowedOrganizations) + }) + } +} diff --git a/pkg/login/social/gitlab_oauth.go b/pkg/login/social/gitlab_oauth.go index 70d2eb23730..08bed52a2b4 100644 --- a/pkg/login/social/gitlab_oauth.go +++ b/pkg/login/social/gitlab_oauth.go @@ -12,12 +12,14 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" ) const ( - groupPerPage = 50 - accessLevelGuest = "10" + groupPerPage = 50 + accessLevelGuest = "10" + gitlabProviderName = "gitlab" ) type SocialGitlab struct { @@ -47,6 +49,24 @@ type userData struct { IsGrafanaAdmin *bool `json:"-"` } +func NewGitLabProvider(settings map[string]any, cfg *setting.Cfg, features *featuremgmt.FeatureManager) (*SocialGitlab, error) { + info, err := createOAuthInfoFromKeyValues(settings) + if err != nil { + return nil, err + } + + config := createOAuthConfig(info, cfg, gitlabProviderName) + provider := &SocialGitlab{ + SocialBase: newSocialBase(gitlabProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + apiUrl: info.ApiUrl, + skipOrgRoleSync: cfg.GitLabSkipOrgRoleSync, + // FIXME: Move skipOrgRoleSync to OAuthInfo + // skipOrgRoleSync: info.SkipOrgRoleSync + } + + return provider, nil +} + func (s *SocialGitlab) getGroups(ctx context.Context, client *http.Client) []string { groups := make([]string, 0) nextPage := new(int) @@ -162,6 +182,10 @@ func (s *SocialGitlab) UserInfo(ctx context.Context, client *http.Client, token return userInfo, nil } +func (s *SocialGitlab) GetOAuthInfo() *OAuthInfo { + return s.info +} + func (s *SocialGitlab) extractFromAPI(ctx context.Context, client *http.Client, token *oauth2.Token) (*userData, error) { apiResp := &apiData{} response, err := s.httpGet(ctx, client, s.apiUrl+"/user") diff --git a/pkg/login/social/gitlab_oauth_test.go b/pkg/login/social/gitlab_oauth_test.go index a12c536d0cb..6b20fb7a14d 100644 --- a/pkg/login/social/gitlab_oauth_test.go +++ b/pkg/login/social/gitlab_oauth_test.go @@ -15,7 +15,9 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/oauth2" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/setting" ) const ( @@ -36,12 +38,9 @@ const ( func TestSocialGitlab_UserInfo(t *testing.T) { var nilPointer *bool - provider := SocialGitlab{ - SocialBase: &SocialBase{ - log: newLogger("gitlab_oauth_test", "debug"), - }, - skipOrgRoleSync: false, - } + + provider, err := NewGitLabProvider(map[string]any{"skip_org_role_sync": false}, &setting.Cfg{}, featuremgmt.WithFeatures()) + require.NoError(t, err) type conf struct { AllowAssignGrafanaAdmin bool @@ -217,7 +216,7 @@ func TestSocialGitlab_extractFromToken(t *testing.T) { switch r.URL.Path { case "/oauth/token": // Return a dummy access token - _ = json.NewEncoder(w).Encode(map[string]interface{}{ + _ = json.NewEncoder(w).Encode(map[string]any{ "access_token": "dummy_access_token", "token_type": "Bearer", }) @@ -347,26 +346,27 @@ func TestSocialGitlab_extractFromToken(t *testing.T) { // Create a test client with a dummy token client := oauth2.NewClient(context.Background(), &tokenSource{accessToken: "dummy_access_token"}) - // Create a test SocialGitlab instance - s := &SocialGitlab{ - SocialBase: &SocialBase{ - Config: tc.config, - log: newLogger("test", "debug"), - allowSignup: false, - allowedDomains: []string{}, - roleAttributePath: "", - roleAttributeStrict: false, - autoAssignOrgRole: "", - skipOrgRoleSync: false, - }, - skipOrgRoleSync: false, - } + s, err := NewGitLabProvider(map[string]any{ + "allowed_domains": []string{}, + "allow_sign_up": false, + "role_attribute_path": "", + "role_attribute_strict": false, + "skip_org_role_sync": false, + "auth_url": tc.config.Endpoint.AuthURL, + "token_url": tc.config.Endpoint.TokenURL, + }, + &setting.Cfg{ + AutoAssignOrgRole: "", + OAuthSkipOrgRoleUpdateSync: false, + }, featuremgmt.WithFeatures()) + + require.NoError(t, err) // Test case: successful extraction token := &oauth2.Token{} // build jwt // header - header := map[string]interface{}{ + header := map[string]any{ "alg": "RS256", "typ": "JWT", "kid": "dummy", @@ -381,7 +381,7 @@ func TestSocialGitlab_extractFromToken(t *testing.T) { // build token idToken := fmt.Sprintf("%s.%s.%s", headerEncoded, payloadEncoded, signatureEncoded) - token = token.WithExtra(map[string]interface{}{"id_token": idToken}) + token = token.WithExtra(map[string]any{"id_token": idToken}) data, err := s.extractFromToken(context.Background(), client, token) if tc.wantErrMessage != "" { require.Error(t, err) @@ -450,12 +450,8 @@ func TestSocialGitlab_GetGroupsNextPage(t *testing.T) { defer mockServer.Close() // Create a SocialGitlab instance with the mock server URL - s := &SocialGitlab{ - apiUrl: mockServer.URL, - SocialBase: &SocialBase{ - log: newLogger("test", "debug"), - }, - } + s, err := NewGitLabProvider(map[string]any{"api_url": mockServer.URL}, &setting.Cfg{}, featuremgmt.WithFeatures()) + require.NoError(t, err) // Call getGroups and verify that it returns all groups expectedGroups := []string{"admins", "editors", "viewers", "serveradmins"} diff --git a/pkg/login/social/google_oauth.go b/pkg/login/social/google_oauth.go index ce76a6863cb..bc56b5e9b46 100644 --- a/pkg/login/social/google_oauth.go +++ b/pkg/login/social/google_oauth.go @@ -14,9 +14,12 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -const legacyAPIURL = "https://www.googleapis.com/oauth2/v1/userinfo" -const googleIAMGroupsEndpoint = "https://content-cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups" -const googleIAMScope = "https://www.googleapis.com/auth/cloud-identity.groups.readonly" +const ( + legacyAPIURL = "https://www.googleapis.com/oauth2/v1/userinfo" + googleIAMGroupsEndpoint = "https://content-cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups" + googleIAMScope = "https://www.googleapis.com/auth/cloud-identity.groups.readonly" + googleProviderName = "google" +) type SocialGoogle struct { *SocialBase @@ -33,6 +36,29 @@ type googleUserData struct { rawJSON []byte `json:"-"` } +func NewGoogleProvider(settings map[string]any, cfg *setting.Cfg, features *featuremgmt.FeatureManager) (*SocialGoogle, error) { + info, err := createOAuthInfoFromKeyValues(settings) + if err != nil { + return nil, err + } + + config := createOAuthConfig(info, cfg, googleProviderName) + provider := &SocialGoogle{ + SocialBase: newSocialBase(googleProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + hostedDomain: info.HostedDomain, + apiUrl: info.ApiUrl, + skipOrgRoleSync: cfg.GoogleSkipOrgRoleSync, + // FIXME: Move skipOrgRoleSync to OAuthInfo + // skipOrgRoleSync: info.SkipOrgRoleSync + } + + if strings.HasPrefix(info.ApiUrl, legacyAPIURL) { + provider.log.Warn("Using legacy Google API URL, please update your configuration") + } + + return provider, nil +} + func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { data, errToken := s.extractFromToken(ctx, client, token) if errToken != nil { @@ -92,6 +118,10 @@ func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token return userInfo, nil } +func (s *SocialGoogle) GetOAuthInfo() *OAuthInfo { + return s.info +} + type googleAPIData struct { ID string `json:"id"` Name string `json:"name"` diff --git a/pkg/login/social/google_oauth_test.go b/pkg/login/social/google_oauth_test.go index 96aa302d490..b5785bee133 100644 --- a/pkg/login/social/google_oauth_test.go +++ b/pkg/login/social/google_oauth_test.go @@ -14,8 +14,9 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/oauth2" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" ) func TestSocialGoogle_retrieveGroups(t *testing.T) { @@ -180,21 +181,23 @@ func TestSocialGoogle_retrieveGroups(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := &SocialGoogle{ - SocialBase: &SocialBase{ - Config: &oauth2.Config{Scopes: tt.fields.Scopes}, - log: log.NewNopLogger(), - allowSignup: false, - allowAssignGrafanaAdmin: false, - allowedDomains: []string{}, - roleAttributePath: "", - roleAttributeStrict: false, - autoAssignOrgRole: "", - skipOrgRoleSync: false, + s, err := NewGoogleProvider(map[string]any{ + "api_url": "", + "scopes": tt.fields.Scopes, + "hosted_domain": "", + "allowed_domains": []string{}, + "allow_sign_up": false, + "role_attribute_path": "", + "role_attribute_strict": false, + "allow_assign_grafana_admin": false, + }, + &setting.Cfg{ + AutoAssignOrgRole: "", + GoogleSkipOrgRoleSync: false, }, - hostedDomain: "", - apiUrl: "", - } + featuremgmt.WithFeatures()) + require.NoError(t, err) + got, err := s.retrieveGroups(context.Background(), tt.args.client, tt.args.userData) if (err != nil) != tt.wantErr { t.Errorf("SocialGoogle.retrieveGroups() error = %v, wantErr %v", err, tt.wantErr) @@ -632,19 +635,20 @@ func TestSocialGoogle_UserInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := &SocialGoogle{ - apiUrl: tt.fields.apiURL, - SocialBase: &SocialBase{ - Config: &oauth2.Config{Scopes: tt.fields.Scopes}, - log: log.NewNopLogger(), - allowSignup: false, - allowedGroups: tt.fields.allowedGroups, - roleAttributePath: tt.fields.roleAttributePath, - roleAttributeStrict: tt.fields.roleAttributeStrict, - allowAssignGrafanaAdmin: tt.fields.allowAssignGrafanaAdmin, + s, err := NewGoogleProvider(map[string]any{ + "api_url": tt.fields.apiURL, + "scopes": tt.fields.Scopes, + "allowed_groups": tt.fields.allowedGroups, + "allow_sign_up": false, + "role_attribute_path": tt.fields.roleAttributePath, + "role_attribute_strict": tt.fields.roleAttributeStrict, + "allow_assign_grafana_admin": tt.fields.allowAssignGrafanaAdmin, + }, + &setting.Cfg{ + GoogleSkipOrgRoleSync: tt.fields.skipOrgRoleSync, }, - skipOrgRoleSync: tt.fields.skipOrgRoleSync, - } + featuremgmt.WithFeatures()) + require.NoError(t, err) gotData, err := s.UserInfo(context.Background(), tt.args.client, tt.args.token) if tt.wantErr { diff --git a/pkg/login/social/grafana_com_oauth.go b/pkg/login/social/grafana_com_oauth.go index a4899b4e745..24a1f908265 100644 --- a/pkg/login/social/grafana_com_oauth.go +++ b/pkg/login/social/grafana_com_oauth.go @@ -9,9 +9,14 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) +const grafanaComProviderName = "grafana_com" + type SocialGrafanaCom struct { *SocialBase url string @@ -23,6 +28,30 @@ type OrgRecord struct { Login string `json:"login"` } +func NewGrafanaComProvider(settings map[string]any, cfg *setting.Cfg, features *featuremgmt.FeatureManager) (*SocialGrafanaCom, error) { + info, err := createOAuthInfoFromKeyValues(settings) + if err != nil { + return nil, err + } + + // Override necessary settings + info.AuthUrl = cfg.GrafanaComURL + "/oauth2/authorize" + info.TokenUrl = cfg.GrafanaComURL + "/api/oauth2/token" + info.AuthStyle = "inheader" + + config := createOAuthConfig(info, cfg, grafanaComProviderName) + provider := &SocialGrafanaCom{ + SocialBase: newSocialBase(grafanaComProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + url: cfg.GrafanaComURL, + allowedOrganizations: util.SplitString(info.Extra["allowed_organizations"]), + skipOrgRoleSync: cfg.GrafanaComSkipOrgRoleSync, + // FIXME: Move skipOrgRoleSync to OAuthInfo + // skipOrgRoleSync: info.SkipOrgRoleSync + } + + return provider, nil +} + func (s *SocialGrafanaCom) IsEmailAllowed(email string) bool { return true } @@ -86,3 +115,7 @@ func (s *SocialGrafanaCom) UserInfo(ctx context.Context, client *http.Client, _ return userInfo, nil } + +func (s *SocialGrafanaCom) GetOAuthInfo() *OAuthInfo { + return s.info +} diff --git a/pkg/login/social/grafana_com_oauth_test.go b/pkg/login/social/grafana_com_oauth_test.go index 3739ec38ad0..a937cb51a9b 100644 --- a/pkg/login/social/grafana_com_oauth_test.go +++ b/pkg/login/social/grafana_com_oauth_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "testing" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" ) @@ -23,11 +25,8 @@ const ( ) func TestSocialGrafanaCom_UserInfo(t *testing.T) { - provider := SocialGrafanaCom{ - SocialBase: &SocialBase{ - log: newLogger("grafana_com_oauth_test", "debug"), - }, - } + provider, err := NewGrafanaComProvider(map[string]any{}, &setting.Cfg{}, featuremgmt.WithFeatures()) + require.NoError(t, err) type conf struct { skipOrgRoleSync bool @@ -93,3 +92,40 @@ func TestSocialGrafanaCom_UserInfo(t *testing.T) { }) } } + +func TestSocialGrafanaCom_InitializeExtraFields(t *testing.T) { + type settingFields struct { + allowedOrganizations []string + } + testCases := []struct { + name string + settings map[string]any + want settingFields + }{ + { + name: "allowedOrganizations is not set", + settings: map[string]any{}, + want: settingFields{ + allowedOrganizations: []string{}, + }, + }, + { + name: "allowedOrganizations is set", + settings: map[string]any{ + "allowed_organizations": "uuid-1234,uuid-5678", + }, + want: settingFields{ + allowedOrganizations: []string{"uuid-1234", "uuid-5678"}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s, err := NewGrafanaComProvider(tc.settings, &setting.Cfg{}, featuremgmt.WithFeatures()) + require.NoError(t, err) + + require.Equal(t, tc.want.allowedOrganizations, s.allowedOrganizations) + }) + } +} diff --git a/pkg/login/social/okta_oauth.go b/pkg/login/social/okta_oauth.go index 7acb64dd08d..6d6c2a9de6e 100644 --- a/pkg/login/social/okta_oauth.go +++ b/pkg/login/social/okta_oauth.go @@ -11,8 +11,12 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" ) +const oktaProviderName = "okta" + type SocialOkta struct { *SocialBase apiUrl string @@ -39,6 +43,29 @@ type OktaClaims struct { Name string `json:"name"` } +func NewOktaProvider(settings map[string]any, cfg *setting.Cfg, features *featuremgmt.FeatureManager) (*SocialOkta, error) { + info, err := createOAuthInfoFromKeyValues(settings) + if err != nil { + return nil, err + } + + config := createOAuthConfig(info, cfg, oktaProviderName) + provider := &SocialOkta{ + SocialBase: newSocialBase(oktaProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + apiUrl: info.ApiUrl, + allowedGroups: info.AllowedGroups, + // FIXME: Move skipOrgRoleSync to OAuthInfo + // skipOrgRoleSync: info.SkipOrgRoleSync + skipOrgRoleSync: cfg.OktaSkipOrgRoleSync, + } + + if info.UseRefreshToken && features.IsEnabledGlobally(featuremgmt.FlagAccessTokenExpirationCheck) { + appendUniqueScope(config, OfflineAccessScope) + } + + return provider, nil +} + func (claims *OktaClaims) extractEmail() string { if claims.Email == "" && claims.PreferredUsername != "" { return claims.PreferredUsername @@ -107,6 +134,10 @@ func (s *SocialOkta) UserInfo(ctx context.Context, client *http.Client, token *o }, nil } +func (s *SocialOkta) GetOAuthInfo() *OAuthInfo { + return s.info +} + func (s *SocialOkta) extractAPI(ctx context.Context, data *OktaUserInfoJson, client *http.Client) error { rawUserInfoResponse, err := s.httpGet(ctx, client, s.apiUrl) if err != nil { @@ -134,6 +165,7 @@ func (s *SocialOkta) GetGroups(data *OktaUserInfoJson) []string { return groups } +// TODO: remove this in a separate PR and use the isGroupMember from the social.go func (s *SocialOkta) IsGroupMember(groups []string) bool { if len(s.allowedGroups) == 0 { return true diff --git a/pkg/login/social/okta_oauth_test.go b/pkg/login/social/okta_oauth_test.go index b6db81fd9bf..1b0cdff3879 100644 --- a/pkg/login/social/okta_oauth_test.go +++ b/pkg/login/social/okta_oauth_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" ) func TestSocialOkta_UserInfo(t *testing.T) { @@ -95,14 +96,22 @@ func TestSocialOkta_UserInfo(t *testing.T) { } })) defer server.Close() - provider := &SocialOkta{ - SocialBase: newSocialBase("okta", &oauth2.Config{}, - &OAuthInfo{RoleAttributePath: tt.RoleAttributePath}, tt.autoAssignOrgRole, false, *featuremgmt.WithFeatures()), - apiUrl: server.URL + "/user", - skipOrgRoleSync: tt.settingSkipOrgRoleSync, - } - provider.allowAssignGrafanaAdmin = tt.allowAssignGrafanaAdmin - provider.roleAttributePath = tt.RoleAttributePath + + provider, err := NewOktaProvider( + map[string]any{ + "api_url": server.URL + "/user", + "role_attribute_path": tt.RoleAttributePath, + "allow_assign_grafana_admin": tt.allowAssignGrafanaAdmin, + "skip_org_role_sync": tt.settingSkipOrgRoleSync, + }, + &setting.Cfg{ + OktaSkipOrgRoleSync: tt.settingSkipOrgRoleSync, + AutoAssignOrgRole: tt.autoAssignOrgRole, + OAuthSkipOrgRoleUpdateSync: false, + }, + featuremgmt.WithFeatures()) + require.NoError(t, err) + // create a oauth2 token with a id_token staticToken := oauth2.Token{ AccessToken: "", diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index 108842fe281..f95b196018b 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -29,7 +29,6 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/supportbundles" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" ) const ( @@ -45,34 +44,37 @@ type SocialService struct { } type OAuthInfo struct { - ApiUrl string `toml:"api_url"` - AuthUrl string `toml:"auth_url"` - ClientId string `toml:"client_id"` - ClientSecret string `toml:"-"` - EmailAttributeName string `toml:"email_attribute_name"` - EmailAttributePath string `toml:"email_attribute_path"` - GroupsAttributePath string `toml:"groups_attribute_path"` - HostedDomain string `toml:"hosted_domain"` - Icon string `toml:"icon"` - Name string `toml:"name"` - RoleAttributePath string `toml:"role_attribute_path"` - TeamIdsAttributePath string `toml:"team_ids_attribute_path"` - TeamsUrl string `toml:"teams_url"` - TlsClientCa string `toml:"tls_client_ca"` - TlsClientCert string `toml:"tls_client_cert"` - TlsClientKey string `toml:"tls_client_key"` - TokenUrl string `toml:"token_url"` - AllowedDomains []string `toml:"allowed_domains"` - AllowedGroups []string `toml:"allowed_groups"` - Scopes []string `toml:"scopes"` - AllowAssignGrafanaAdmin bool `toml:"allow_assign_grafana_admin"` - AllowSignup bool `toml:"allow_signup"` - AutoLogin bool `toml:"auto_login"` - Enabled bool `toml:"enabled"` - RoleAttributeStrict bool `toml:"role_attribute_strict"` - TlsSkipVerify bool `toml:"tls_skip_verify"` - UsePKCE bool `toml:"use_pkce"` - UseRefreshToken bool `toml:"use_refresh_token"` + ApiUrl string `mapstructure:"api_url"` + AuthUrl string `mapstructure:"auth_url"` + AuthStyle string `mapstructure:"auth_style"` + ClientId string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + EmailAttributeName string `mapstructure:"email_attribute_name"` + EmailAttributePath string `mapstructure:"email_attribute_path"` + EmptyScopes bool `mapstructure:"empty_scopes"` + GroupsAttributePath string `mapstructure:"groups_attribute_path"` + HostedDomain string `mapstructure:"hosted_domain"` + Icon string `mapstructure:"icon"` + Name string `mapstructure:"name"` + RoleAttributePath string `mapstructure:"role_attribute_path"` + TeamIdsAttributePath string `mapstructure:"team_ids_attribute_path"` + TeamsUrl string `mapstructure:"teams_url"` + TlsClientCa string `mapstructure:"tls_client_ca"` + TlsClientCert string `mapstructure:"tls_client_cert"` + TlsClientKey string `mapstructure:"tls_client_key"` + TokenUrl string `mapstructure:"token_url"` + AllowedDomains []string `mapstructure:"allowed_domains"` + AllowedGroups []string `mapstructure:"allowed_groups"` + Scopes []string `mapstructure:"scopes"` + AllowAssignGrafanaAdmin bool `mapstructure:"allow_assign_grafana_admin"` + AllowSignup bool `mapstructure:"allow_sign_up"` + AutoLogin bool `mapstructure:"auto_login"` + Enabled bool `mapstructure:"enabled"` + RoleAttributeStrict bool `mapstructure:"role_attribute_strict"` + TlsSkipVerify bool `mapstructure:"tls_skip_verify_insecure"` + UsePKCE bool `mapstructure:"use_pkce"` + UseRefreshToken bool `mapstructure:"use_refresh_token"` + Extra map[string]string `mapstructure:",remain"` } func ProvideService(cfg *setting.Cfg, @@ -93,40 +95,11 @@ func ProvideService(cfg *setting.Cfg, for _, name := range allOauthes { sec := cfg.Raw.Section("auth." + name) - info := &OAuthInfo{ - ClientId: sec.Key("client_id").String(), - ClientSecret: sec.Key("client_secret").String(), - Scopes: util.SplitString(sec.Key("scopes").String()), - AuthUrl: sec.Key("auth_url").String(), - TokenUrl: sec.Key("token_url").String(), - ApiUrl: sec.Key("api_url").String(), - TeamsUrl: sec.Key("teams_url").String(), - Enabled: sec.Key("enabled").MustBool(), - EmailAttributeName: sec.Key("email_attribute_name").String(), - EmailAttributePath: sec.Key("email_attribute_path").String(), - RoleAttributePath: sec.Key("role_attribute_path").String(), - RoleAttributeStrict: sec.Key("role_attribute_strict").MustBool(), - GroupsAttributePath: sec.Key("groups_attribute_path").String(), - TeamIdsAttributePath: sec.Key("team_ids_attribute_path").String(), - AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()), - HostedDomain: sec.Key("hosted_domain").String(), - AllowSignup: sec.Key("allow_sign_up").MustBool(), - Name: sec.Key("name").MustString(name), - Icon: sec.Key("icon").String(), - TlsClientCert: sec.Key("tls_client_cert").String(), - TlsClientKey: sec.Key("tls_client_key").String(), - TlsClientCa: sec.Key("tls_client_ca").String(), - TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(), - UsePKCE: sec.Key("use_pkce").MustBool(), - UseRefreshToken: sec.Key("use_refresh_token").MustBool(false), - AllowAssignGrafanaAdmin: sec.Key("allow_assign_grafana_admin").MustBool(false), - AutoLogin: sec.Key("auto_login").MustBool(false), - AllowedGroups: util.SplitString(sec.Key("allowed_groups").String()), - } - - // when empty_scopes parameter exists and is true, overwrite scope with empty value - if sec.Key("empty_scopes").MustBool() { - info.Scopes = []string{} + settingsKVs := convertIniSectionToMap(sec) + info, err := createOAuthInfoFromKeyValues(settingsKVs) + if err != nil { + ss.log.Error("Failed to create OAuthInfo for provider", "error", err, "provider", name) + continue } if !info.Enabled { @@ -137,133 +110,13 @@ func ProvideService(cfg *setting.Cfg, name = grafanaCom } - ss.oAuthProvider[name] = info - - var authStyle oauth2.AuthStyle - switch strings.ToLower(sec.Key("auth_style").String()) { - case "inparams": - authStyle = oauth2.AuthStyleInParams - case "inheader": - authStyle = oauth2.AuthStyleInHeader - case "autodetect", "": - authStyle = oauth2.AuthStyleAutoDetect - default: - ss.log.Warn("Invalid auth style specified, defaulting to auth style AutoDetect", "auth_style", sec.Key("auth_style").String()) - authStyle = oauth2.AuthStyleAutoDetect + conn, err := ss.createOAuthConnector(name, settingsKVs, cfg, features, cache) + if err != nil { + ss.log.Error("Failed to create OAuth provider", "error", err, "provider", name) } - config := oauth2.Config{ - ClientID: info.ClientId, - ClientSecret: info.ClientSecret, - Endpoint: oauth2.Endpoint{ - AuthURL: info.AuthUrl, - TokenURL: info.TokenUrl, - AuthStyle: authStyle, - }, - RedirectURL: strings.TrimSuffix(cfg.AppURL, "/") + SocialBaseUrl + name, - Scopes: info.Scopes, - } - - // GitHub. - if name == "github" { - ss.socialMap["github"] = &SocialGithub{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), - apiUrl: info.ApiUrl, - teamIds: sec.Key("team_ids").Ints(","), - allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), - skipOrgRoleSync: cfg.GitHubSkipOrgRoleSync, - } - } - - // GitLab. - if name == "gitlab" { - ss.socialMap["gitlab"] = &SocialGitlab{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), - apiUrl: info.ApiUrl, - skipOrgRoleSync: cfg.GitLabSkipOrgRoleSync, - } - } - - // Google. - if name == "google" { - if strings.HasPrefix(info.ApiUrl, legacyAPIURL) { - ss.log.Warn("Using legacy Google API URL, please update your configuration") - } - ss.socialMap["google"] = &SocialGoogle{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), - hostedDomain: info.HostedDomain, - apiUrl: info.ApiUrl, - skipOrgRoleSync: cfg.GoogleSkipOrgRoleSync, - } - } - - // AzureAD. - if name == "azuread" { - ss.socialMap["azuread"] = &SocialAzureAD{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), - cache: cache, - allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), - forceUseGraphAPI: sec.Key("force_use_graph_api").MustBool(false), - skipOrgRoleSync: cfg.AzureADSkipOrgRoleSync, - } - if info.UseRefreshToken && features.IsEnabledGlobally(featuremgmt.FlagAccessTokenExpirationCheck) { - appendUniqueScope(&config, OfflineAccessScope) - } - } - - // Okta - if name == "okta" { - ss.socialMap["okta"] = &SocialOkta{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), - apiUrl: info.ApiUrl, - allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), - skipOrgRoleSync: cfg.OktaSkipOrgRoleSync, - } - if info.UseRefreshToken && features.IsEnabledGlobally(featuremgmt.FlagAccessTokenExpirationCheck) { - appendUniqueScope(&config, OfflineAccessScope) - } - } - - // Generic - Uses the same scheme as GitHub. - if name == "generic_oauth" { - ss.socialMap["generic_oauth"] = &SocialGenericOAuth{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), - apiUrl: info.ApiUrl, - teamsUrl: info.TeamsUrl, - emailAttributeName: info.EmailAttributeName, - emailAttributePath: info.EmailAttributePath, - nameAttributePath: sec.Key("name_attribute_path").String(), - groupsAttributePath: info.GroupsAttributePath, - loginAttributePath: sec.Key("login_attribute_path").String(), - idTokenAttributeName: sec.Key("id_token_attribute_name").String(), - teamIdsAttributePath: sec.Key("team_ids_attribute_path").String(), - teamIds: sec.Key("team_ids").Strings(","), - allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), - allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), - skipOrgRoleSync: cfg.GenericOAuthSkipOrgRoleSync, - } - } - - if name == grafanaCom { - config = oauth2.Config{ - ClientID: info.ClientId, - ClientSecret: info.ClientSecret, - Endpoint: oauth2.Endpoint{ - AuthURL: cfg.GrafanaComURL + "/oauth2/authorize", - TokenURL: cfg.GrafanaComURL + "/api/oauth2/token", - AuthStyle: oauth2.AuthStyleInHeader, - }, - RedirectURL: strings.TrimSuffix(cfg.AppURL, "/") + SocialBaseUrl + name, - Scopes: info.Scopes, - } - - ss.socialMap[grafanaCom] = &SocialGrafanaCom{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), - url: cfg.GrafanaComURL, - allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), - skipOrgRoleSync: cfg.GrafanaComSkipOrgRoleSync, - } - } + ss.socialMap[name] = conn + ss.oAuthProvider[name] = ss.socialMap[name].GetOAuthInfo() } ss.registerSupportBundleCollectors(bundleRegistry) @@ -292,6 +145,8 @@ type SocialConnector interface { IsEmailAllowed(email string) bool IsSignupAllowed() bool + GetOAuthInfo() *OAuthInfo + AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string Exchange(ctx context.Context, code string, authOptions ...oauth2.AuthCodeOption) (*oauth2.Token, error) Client(ctx context.Context, t *oauth2.Token) *http.Client @@ -301,6 +156,7 @@ type SocialConnector interface { type SocialBase struct { *oauth2.Config + info *OAuthInfo log log.Logger allowSignup bool allowAssignGrafanaAdmin bool @@ -353,6 +209,7 @@ func newSocialBase(name string, return &SocialBase{ Config: config, + info: info, log: logger, allowSignup: info.AllowSignup, allowAssignGrafanaAdmin: info.AllowAssignGrafanaAdmin, @@ -553,8 +410,8 @@ func (ss *SocialService) GetOAuthInfoProviders() map[string]*OAuthInfo { return ss.oAuthProvider } -func (ss *SocialService) getUsageStats(ctx context.Context) (map[string]interface{}, error) { - m := map[string]interface{}{} +func (ss *SocialService) getUsageStats(ctx context.Context) (map[string]any, error) { + m := map[string]any{} authTypes := map[string]bool{} for provider, enabled := range ss.GetOAuthProviders() { @@ -589,7 +446,7 @@ func (s *SocialBase) isGroupMember(groups []string) bool { return false } -func (s *SocialBase) retrieveRawIDToken(idToken interface{}) ([]byte, error) { +func (s *SocialBase) retrieveRawIDToken(idToken any) ([]byte, error) { tokenString, ok := idToken.(string) if !ok { return nil, fmt.Errorf("id_token is not a string: %v", idToken) @@ -611,7 +468,7 @@ func (s *SocialBase) retrieveRawIDToken(idToken interface{}) ([]byte, error) { return nil, fmt.Errorf("error base64 decoding header: %w", err) } - var header map[string]interface{} + var header map[string]any if err := json.Unmarshal(headerBytes, &header); err != nil { return nil, fmt.Errorf("error deserializing header: %w", err) } @@ -645,6 +502,27 @@ func (s *SocialBase) retrieveRawIDToken(idToken interface{}) ([]byte, error) { return rawJSON, nil } +func (ss *SocialService) createOAuthConnector(name string, settings map[string]any, cfg *setting.Cfg, features *featuremgmt.FeatureManager, cache remotecache.CacheStorage) (SocialConnector, error) { + switch name { + case azureADProviderName: + return NewAzureADProvider(settings, cfg, features, cache) + case genericOAuthProviderName: + return NewGenericOAuthProvider(settings, cfg, features) + case gitHubProviderName: + return NewGitHubProvider(settings, cfg, features) + case gitlabProviderName: + return NewGitLabProvider(settings, cfg, features) + case googleProviderName: + return NewGoogleProvider(settings, cfg, features) + case grafanaComProviderName: + return NewGrafanaComProvider(settings, cfg, features) + case oktaProviderName: + return NewOktaProvider(settings, cfg, features) + default: + return nil, fmt.Errorf("unknown oauth provider: %s", name) + } +} + func appendUniqueScope(config *oauth2.Config, scope string) { if !slices.Contains(config.Scopes, OfflineAccessScope) { config.Scopes = append(config.Scopes, OfflineAccessScope) diff --git a/pkg/login/socialtest/social_connector_mock.go b/pkg/login/socialtest/social_connector_mock.go index 07c50306953..abac2e322a6 100644 --- a/pkg/login/socialtest/social_connector_mock.go +++ b/pkg/login/socialtest/social_connector_mock.go @@ -90,6 +90,22 @@ func (_m *MockSocialConnector) Exchange(ctx context.Context, code string, authOp return r0, r1 } +// GetOAuthInfo provides a mock function with given fields: +func (_m *MockSocialConnector) GetOAuthInfo() *social.OAuthInfo { + ret := _m.Called() + + var r0 *social.OAuthInfo + if rf, ok := ret.Get(0).(func() *social.OAuthInfo); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*social.OAuthInfo) + } + } + + return r0 +} + // IsEmailAllowed provides a mock function with given fields: email func (_m *MockSocialConnector) IsEmailAllowed(email string) bool { ret := _m.Called(email) diff --git a/pkg/services/ssosettings/strategies/oauth_strategy.go b/pkg/services/ssosettings/strategies/oauth_strategy.go index 114062d6e55..515431deaa1 100644 --- a/pkg/services/ssosettings/strategies/oauth_strategy.go +++ b/pkg/services/ssosettings/strategies/oauth_strategy.go @@ -29,29 +29,32 @@ func (s *OAuthStrategy) IsMatch(provider string) bool { return s.supportedProvidersRegex.MatchString(provider) } -func (s *OAuthStrategy) ParseConfigFromSystem(_ context.Context) (map[string]interface{}, error) { +func (s *OAuthStrategy) ParseConfigFromSystem(_ context.Context) (map[string]any, error) { section := s.cfg.SectionWithEnvOverrides("auth." + s.provider) - result := map[string]interface{}{ - "client_id": section.Key("client_id").Value(), - "client_secret": section.Key("client_secret").Value(), - "scopes": section.Key("scopes").Value(), - "auth_url": section.Key("auth_url").Value(), - "token_url": section.Key("token_url").Value(), - "api_url": section.Key("api_url").Value(), - "teams_url": section.Key("teams_url").Value(), - "enabled": section.Key("enabled").MustBool(false), - "email_attribute_name": section.Key("email_attribute_name").Value(), - "email_attribute_path": section.Key("email_attribute_path").Value(), - "role_attribute_path": section.Key("role_attribute_path").Value(), - "role_attribute_strict": section.Key("role_attribute_strict").MustBool(false), - "groups_attribute_path": section.Key("groups_attribute_path").Value(), - "team_ids_attribute_path": section.Key("team_ids_attribute_path").Value(), - "allowed_domains": section.Key("allowed_domains").Value(), - "hosted_domain": section.Key("hosted_domain").Value(), - "allow_sign_up": section.Key("allow_sign_up").MustBool(true), - "name": section.Key("name").MustString("default name"), // TODO: change this default value - "icon": section.Key("icon").Value(), + // TODO: load the provider specific keys separately + result := map[string]any{ + "client_id": section.Key("client_id").Value(), + "client_secret": section.Key("client_secret").Value(), + "scopes": section.Key("scopes").Value(), + "auth_url": section.Key("auth_url").Value(), + "token_url": section.Key("token_url").Value(), + "api_url": section.Key("api_url").Value(), + "teams_url": section.Key("teams_url").Value(), + "enabled": section.Key("enabled").MustBool(false), + "email_attribute_name": section.Key("email_attribute_name").Value(), + "email_attribute_path": section.Key("email_attribute_path").Value(), + "role_attribute_path": section.Key("role_attribute_path").Value(), + "role_attribute_strict": section.Key("role_attribute_strict").MustBool(false), + "groups_attribute_path": section.Key("groups_attribute_path").Value(), + "team_ids_attribute_path": section.Key("team_ids_attribute_path").Value(), + "allowed_domains": section.Key("allowed_domains").Value(), + "hosted_domain": section.Key("hosted_domain").Value(), + "allow_sign_up": section.Key("allow_sign_up").MustBool(true), + "name": section.Key("name").MustString("default name"), // TODO: change this default value + "icon": section.Key("icon").Value(), + // TODO: @mgyongyosi move skipOrgRoleSync here in a separate PR + // "skip_org_role_sync": section.Key("skip_org_role_sync").MustBool(false), "tls_client_cert": section.Key("tls_client_cert").Value(), "tls_client_key": section.Key("tls_client_key").Value(), "tls_client_ca": section.Key("tls_client_ca").Value(),