mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
JWT Authentication: Add support for specifying groups in auth.jwt for teamsync (#82175)
* merge JSON search logic * document public methods * improve test coverage * use separate JWT setting struct * correct use of cfg.JWTAuth * add group tests * fix DynMap typing * add settings to default ini * add groups option to devenv path * fix test * lint * revert jwt-proxy change * remove redundant check * fix parallel test
This commit is contained in:
parent
32a1f3955a
commit
6f62d970e3
@ -840,6 +840,7 @@ key_file =
|
|||||||
key_id =
|
key_id =
|
||||||
role_attribute_path =
|
role_attribute_path =
|
||||||
role_attribute_strict = false
|
role_attribute_strict = false
|
||||||
|
groups_attribute_path =
|
||||||
auto_sign_up = false
|
auto_sign_up = false
|
||||||
url_login = false
|
url_login = false
|
||||||
allow_assign_grafana_admin = false
|
allow_assign_grafana_admin = false
|
||||||
|
@ -774,6 +774,7 @@
|
|||||||
# Use in conjunction with key_file in case the JWT token's header specifies a key ID in "kid" field
|
# Use in conjunction with key_file in case the JWT token's header specifies a key ID in "kid" field
|
||||||
;key_id = some-key-id
|
;key_id = some-key-id
|
||||||
;role_attribute_path =
|
;role_attribute_path =
|
||||||
|
;groups_attribute_path =
|
||||||
;role_attribute_strict = false
|
;role_attribute_strict = false
|
||||||
;auto_sign_up = false
|
;auto_sign_up = false
|
||||||
;url_login = false
|
;url_login = false
|
||||||
@ -1639,4 +1640,3 @@
|
|||||||
[public_dashboards]
|
[public_dashboards]
|
||||||
# Set to false to disable public dashboards
|
# Set to false to disable public dashboards
|
||||||
;enabled = true
|
;enabled = true
|
||||||
|
|
||||||
|
@ -24,6 +24,7 @@ expect_claims = {"iss": "http://env.grafana.local:8087/realms/grafana", "azp": "
|
|||||||
auto_sign_up = true
|
auto_sign_up = true
|
||||||
role_attribute_path = contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin' || contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer'
|
role_attribute_path = contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin' || contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer'
|
||||||
role_attribute_strict = false
|
role_attribute_strict = false
|
||||||
|
groups_attribute_path = groups[]
|
||||||
allow_assign_grafana_admin = true
|
allow_assign_grafana_admin = true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -320,9 +320,9 @@ func Test_AdminUpdateUserPermissions(t *testing.T) {
|
|||||||
case login.GenericOAuthModule:
|
case login.GenericOAuthModule:
|
||||||
socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{AllowAssignGrafanaAdmin: tc.allowAssignGrafanaAdmin, Enabled: tc.authEnabled, SkipOrgRoleSync: tc.skipOrgRoleSync}
|
socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{AllowAssignGrafanaAdmin: tc.allowAssignGrafanaAdmin, Enabled: tc.authEnabled, SkipOrgRoleSync: tc.skipOrgRoleSync}
|
||||||
case login.JWTModule:
|
case login.JWTModule:
|
||||||
cfg.JWTAuthEnabled = tc.authEnabled
|
cfg.JWTAuth.Enabled = tc.authEnabled
|
||||||
cfg.JWTAuthSkipOrgRoleSync = tc.skipOrgRoleSync
|
cfg.JWTAuth.SkipOrgRoleSync = tc.skipOrgRoleSync
|
||||||
cfg.JWTAuthAllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin
|
cfg.JWTAuth.AllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin
|
||||||
}
|
}
|
||||||
|
|
||||||
hs := &HTTPServer{
|
hs := &HTTPServer{
|
||||||
|
@ -173,8 +173,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
|||||||
AllowOrgCreate: (hs.Cfg.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin,
|
AllowOrgCreate: (hs.Cfg.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin,
|
||||||
AuthProxyEnabled: hs.Cfg.AuthProxyEnabled,
|
AuthProxyEnabled: hs.Cfg.AuthProxyEnabled,
|
||||||
LdapEnabled: hs.Cfg.LDAPAuthEnabled,
|
LdapEnabled: hs.Cfg.LDAPAuthEnabled,
|
||||||
JwtHeaderName: hs.Cfg.JWTAuthHeaderName,
|
JwtHeaderName: hs.Cfg.JWTAuth.HeaderName,
|
||||||
JwtUrlLogin: hs.Cfg.JWTAuthURLLogin,
|
JwtUrlLogin: hs.Cfg.JWTAuth.URLLogin,
|
||||||
AlertingErrorOrTimeout: hs.Cfg.AlertingErrorOrTimeout,
|
AlertingErrorOrTimeout: hs.Cfg.AlertingErrorOrTimeout,
|
||||||
AlertingNoDataOrNullValues: hs.Cfg.AlertingNoDataOrNullValues,
|
AlertingNoDataOrNullValues: hs.Cfg.AlertingNoDataOrNullValues,
|
||||||
AlertingMinInterval: hs.Cfg.AlertingMinInterval,
|
AlertingMinInterval: hs.Cfg.AlertingMinInterval,
|
||||||
@ -321,7 +321,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
|||||||
OAuthSkipOrgRoleUpdateSync: hs.Cfg.OAuthSkipOrgRoleUpdateSync,
|
OAuthSkipOrgRoleUpdateSync: hs.Cfg.OAuthSkipOrgRoleUpdateSync,
|
||||||
SAMLSkipOrgRoleSync: hs.Cfg.SAMLSkipOrgRoleSync,
|
SAMLSkipOrgRoleSync: hs.Cfg.SAMLSkipOrgRoleSync,
|
||||||
LDAPSkipOrgRoleSync: hs.Cfg.LDAPSkipOrgRoleSync,
|
LDAPSkipOrgRoleSync: hs.Cfg.LDAPSkipOrgRoleSync,
|
||||||
JWTAuthSkipOrgRoleSync: hs.Cfg.JWTAuthSkipOrgRoleSync,
|
JWTAuthSkipOrgRoleSync: hs.Cfg.JWTAuth.SkipOrgRoleSync,
|
||||||
GoogleSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GoogleProviderName]),
|
GoogleSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GoogleProviderName]),
|
||||||
GrafanaComSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GrafanaComProviderName]),
|
GrafanaComSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GrafanaComProviderName]),
|
||||||
GenericOAuthSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GenericOAuthProviderName]),
|
GenericOAuthSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GenericOAuthProviderName]),
|
||||||
|
@ -302,9 +302,9 @@ func Test_GetUserByID(t *testing.T) {
|
|||||||
case login.GenericOAuthModule:
|
case login.GenericOAuthModule:
|
||||||
socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{AllowAssignGrafanaAdmin: tc.allowAssignGrafanaAdmin, Enabled: tc.authEnabled, SkipOrgRoleSync: tc.skipOrgRoleSync}
|
socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{AllowAssignGrafanaAdmin: tc.allowAssignGrafanaAdmin, Enabled: tc.authEnabled, SkipOrgRoleSync: tc.skipOrgRoleSync}
|
||||||
case login.JWTModule:
|
case login.JWTModule:
|
||||||
cfg.JWTAuthEnabled = tc.authEnabled
|
cfg.JWTAuth.Enabled = tc.authEnabled
|
||||||
cfg.JWTAuthSkipOrgRoleSync = tc.skipOrgRoleSync
|
cfg.JWTAuth.SkipOrgRoleSync = tc.skipOrgRoleSync
|
||||||
cfg.JWTAuthAllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin
|
cfg.JWTAuth.AllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin
|
||||||
}
|
}
|
||||||
|
|
||||||
hs := &HTTPServer{
|
hs := &HTTPServer{
|
||||||
|
@ -2,8 +2,6 @@ package connectors
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -12,7 +10,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jmespath/go-jmespath"
|
|
||||||
"github.com/mitchellh/mapstructure"
|
"github.com/mitchellh/mapstructure"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
@ -96,63 +93,6 @@ func (s *SocialBase) httpGet(ctx context.Context, client *http.Client, url strin
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SocialBase) searchJSONForAttr(attributePath string, data []byte) (any, error) {
|
|
||||||
if attributePath == "" {
|
|
||||||
return "", errors.New("no attribute path specified")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(data) == 0 {
|
|
||||||
return "", errors.New("empty user info JSON response provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf any
|
|
||||||
if err := json.Unmarshal(data, &buf); err != nil {
|
|
||||||
return "", fmt.Errorf("%v: %w", "failed to unmarshal user info JSON response", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
val, err := jmespath.Search(attributePath, buf)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to search user info JSON response with provided path: %q: %w", attributePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SocialBase) searchJSONForStringAttr(attributePath string, data []byte) (string, error) {
|
|
||||||
val, err := s.searchJSONForAttr(attributePath, data)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
strVal, ok := val.(string)
|
|
||||||
if ok {
|
|
||||||
return strVal, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SocialBase) searchJSONForStringArrayAttr(attributePath string, data []byte) ([]string, error) {
|
|
||||||
val, err := s.searchJSONForAttr(attributePath, data)
|
|
||||||
if err != nil {
|
|
||||||
return []string{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ifArr, ok := val.([]any)
|
|
||||||
if !ok {
|
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := []string{}
|
|
||||||
for _, v := range ifArr {
|
|
||||||
if strVal, ok := v.(string); ok {
|
|
||||||
result = append(result, strVal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createOAuthConfig(info *social.OAuthInfo, cfg *setting.Cfg, defaultName string) *oauth2.Config {
|
func createOAuthConfig(info *social.OAuthInfo, cfg *setting.Cfg, defaultName string) *oauth2.Config {
|
||||||
var authStyle oauth2.AuthStyle
|
var authStyle oauth2.AuthStyle
|
||||||
switch strings.ToLower(info.AuthStyle) {
|
switch strings.ToLower(info.AuthStyle) {
|
||||||
|
@ -363,7 +363,7 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.emailAttributePath != "" {
|
if s.emailAttributePath != "" {
|
||||||
email, err := s.searchJSONForStringAttr(s.emailAttributePath, data.rawJSON)
|
email, err := util.SearchJSONForStringAttr(s.emailAttributePath, data.rawJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("Failed to search JSON for attribute", "error", err)
|
s.log.Error("Failed to search JSON for attribute", "error", err)
|
||||||
} else if email != "" {
|
} else if email != "" {
|
||||||
@ -395,7 +395,7 @@ func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson) string {
|
|||||||
|
|
||||||
if s.loginAttributePath != "" {
|
if s.loginAttributePath != "" {
|
||||||
s.log.Debug("Searching for login among JSON", "loginAttributePath", s.loginAttributePath)
|
s.log.Debug("Searching for login among JSON", "loginAttributePath", s.loginAttributePath)
|
||||||
login, err := s.searchJSONForStringAttr(s.loginAttributePath, data.rawJSON)
|
login, err := util.SearchJSONForStringAttr(s.loginAttributePath, data.rawJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("Failed to search JSON for login attribute", "error", err)
|
s.log.Error("Failed to search JSON for login attribute", "error", err)
|
||||||
}
|
}
|
||||||
@ -415,7 +415,7 @@ func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson) string {
|
|||||||
|
|
||||||
func (s *SocialGenericOAuth) extractUserName(data *UserInfoJson) string {
|
func (s *SocialGenericOAuth) extractUserName(data *UserInfoJson) string {
|
||||||
if s.nameAttributePath != "" {
|
if s.nameAttributePath != "" {
|
||||||
name, err := s.searchJSONForStringAttr(s.nameAttributePath, data.rawJSON)
|
name, err := util.SearchJSONForStringAttr(s.nameAttributePath, data.rawJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("Failed to search JSON for attribute", "error", err)
|
s.log.Error("Failed to search JSON for attribute", "error", err)
|
||||||
} else if name != "" {
|
} else if name != "" {
|
||||||
@ -443,7 +443,7 @@ func (s *SocialGenericOAuth) extractGroups(data *UserInfoJson) ([]string, error)
|
|||||||
return []string{}, nil
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.searchJSONForStringArrayAttr(s.groupsAttributePath, data.rawJSON)
|
return util.SearchJSONForStringSliceAttr(s.groupsAttributePath, data.rawJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SocialGenericOAuth) FetchPrivateEmail(ctx context.Context, client *http.Client) (string, error) {
|
func (s *SocialGenericOAuth) FetchPrivateEmail(ctx context.Context, client *http.Client) (string, error) {
|
||||||
@ -554,7 +554,7 @@ func (s *SocialGenericOAuth) fetchTeamMembershipsFromTeamsUrl(ctx context.Contex
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.searchJSONForStringArrayAttr(s.teamIdsAttributePath, response.Body)
|
return util.SearchJSONForStringSliceAttr(s.teamIdsAttributePath, response.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SocialGenericOAuth) FetchOrganizations(ctx context.Context, client *http.Client) ([]string, bool) {
|
func (s *SocialGenericOAuth) FetchOrganizations(ctx context.Context, client *http.Client) ([]string, bool) {
|
||||||
|
@ -23,208 +23,6 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSearchJSONForEmail(t *testing.T) {
|
|
||||||
t.Run("Given a generic OAuth provider", func(t *testing.T) {
|
|
||||||
provider := NewGenericOAuthProvider(social.NewOAuthInfo(), &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
Name string
|
|
||||||
UserInfoJSONResponse []byte
|
|
||||||
EmailAttributePath string
|
|
||||||
ExpectedResult string
|
|
||||||
ExpectedError string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
Name: "Given an invalid user info JSON response",
|
|
||||||
UserInfoJSONResponse: []byte("{"),
|
|
||||||
EmailAttributePath: "attributes.email",
|
|
||||||
ExpectedResult: "",
|
|
||||||
ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Given an empty user info JSON response and empty JMES path",
|
|
||||||
UserInfoJSONResponse: []byte{},
|
|
||||||
EmailAttributePath: "",
|
|
||||||
ExpectedResult: "",
|
|
||||||
ExpectedError: "no attribute path specified",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Given an empty user info JSON response and valid JMES path",
|
|
||||||
UserInfoJSONResponse: []byte{},
|
|
||||||
EmailAttributePath: "attributes.email",
|
|
||||||
ExpectedResult: "",
|
|
||||||
ExpectedError: "empty user info JSON response provided",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Given a simple user info JSON response and valid JMES path",
|
|
||||||
UserInfoJSONResponse: []byte(`{
|
|
||||||
"attributes": {
|
|
||||||
"email": "grafana@localhost"
|
|
||||||
}
|
|
||||||
}`),
|
|
||||||
EmailAttributePath: "attributes.email",
|
|
||||||
ExpectedResult: "grafana@localhost",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Given a user info JSON response with e-mails array and valid JMES path",
|
|
||||||
UserInfoJSONResponse: []byte(`{
|
|
||||||
"attributes": {
|
|
||||||
"emails": ["grafana@localhost", "admin@localhost"]
|
|
||||||
}
|
|
||||||
}`),
|
|
||||||
EmailAttributePath: "attributes.emails[0]",
|
|
||||||
ExpectedResult: "grafana@localhost",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Given a nested user info JSON response and valid JMES path",
|
|
||||||
UserInfoJSONResponse: []byte(`{
|
|
||||||
"identities": [
|
|
||||||
{
|
|
||||||
"userId": "grafana@localhost"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"userId": "admin@localhost"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`),
|
|
||||||
EmailAttributePath: "identities[0].userId",
|
|
||||||
ExpectedResult: "grafana@localhost",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
provider.emailAttributePath = test.EmailAttributePath
|
|
||||||
t.Run(test.Name, func(t *testing.T) {
|
|
||||||
actualResult, err := provider.searchJSONForStringAttr(test.EmailAttributePath, test.UserInfoJSONResponse)
|
|
||||||
if test.ExpectedError == "" {
|
|
||||||
require.NoError(t, err, "Testing case %q", test.Name)
|
|
||||||
} else {
|
|
||||||
require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name)
|
|
||||||
}
|
|
||||||
require.Equal(t, test.ExpectedResult, actualResult)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSearchJSONForGroups(t *testing.T) {
|
|
||||||
t.Run("Given a generic OAuth provider", func(t *testing.T) {
|
|
||||||
provider := NewGenericOAuthProvider(social.NewOAuthInfo(), &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
Name string
|
|
||||||
UserInfoJSONResponse []byte
|
|
||||||
GroupsAttributePath string
|
|
||||||
ExpectedResult []string
|
|
||||||
ExpectedError string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
Name: "Given an invalid user info JSON response",
|
|
||||||
UserInfoJSONResponse: []byte("{"),
|
|
||||||
GroupsAttributePath: "attributes.groups",
|
|
||||||
ExpectedResult: []string{},
|
|
||||||
ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Given an empty user info JSON response and empty JMES path",
|
|
||||||
UserInfoJSONResponse: []byte{},
|
|
||||||
GroupsAttributePath: "",
|
|
||||||
ExpectedResult: []string{},
|
|
||||||
ExpectedError: "no attribute path specified",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Given an empty user info JSON response and valid JMES path",
|
|
||||||
UserInfoJSONResponse: []byte{},
|
|
||||||
GroupsAttributePath: "attributes.groups",
|
|
||||||
ExpectedResult: []string{},
|
|
||||||
ExpectedError: "empty user info JSON response provided",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Given a simple user info JSON response and valid JMES path",
|
|
||||||
UserInfoJSONResponse: []byte(`{
|
|
||||||
"attributes": {
|
|
||||||
"groups": ["foo", "bar"]
|
|
||||||
}
|
|
||||||
}`),
|
|
||||||
GroupsAttributePath: "attributes.groups[]",
|
|
||||||
ExpectedResult: []string{"foo", "bar"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
provider.groupsAttributePath = test.GroupsAttributePath
|
|
||||||
t.Run(test.Name, func(t *testing.T) {
|
|
||||||
actualResult, err := provider.searchJSONForStringArrayAttr(test.GroupsAttributePath, test.UserInfoJSONResponse)
|
|
||||||
if test.ExpectedError == "" {
|
|
||||||
require.NoError(t, err, "Testing case %q", test.Name)
|
|
||||||
} else {
|
|
||||||
require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name)
|
|
||||||
}
|
|
||||||
require.Equal(t, test.ExpectedResult, actualResult)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSearchJSONForRole(t *testing.T) {
|
|
||||||
t.Run("Given a generic OAuth provider", func(t *testing.T) {
|
|
||||||
provider := NewGenericOAuthProvider(social.NewOAuthInfo(), &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
Name string
|
|
||||||
UserInfoJSONResponse []byte
|
|
||||||
RoleAttributePath string
|
|
||||||
ExpectedResult string
|
|
||||||
ExpectedError string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
Name: "Given an invalid user info JSON response",
|
|
||||||
UserInfoJSONResponse: []byte("{"),
|
|
||||||
RoleAttributePath: "attributes.role",
|
|
||||||
ExpectedResult: "",
|
|
||||||
ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Given an empty user info JSON response and empty JMES path",
|
|
||||||
UserInfoJSONResponse: []byte{},
|
|
||||||
RoleAttributePath: "",
|
|
||||||
ExpectedResult: "",
|
|
||||||
ExpectedError: "no attribute path specified",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Given an empty user info JSON response and valid JMES path",
|
|
||||||
UserInfoJSONResponse: []byte{},
|
|
||||||
RoleAttributePath: "attributes.role",
|
|
||||||
ExpectedResult: "",
|
|
||||||
ExpectedError: "empty user info JSON response provided",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Given a simple user info JSON response and valid JMES path",
|
|
||||||
UserInfoJSONResponse: []byte(`{
|
|
||||||
"attributes": {
|
|
||||||
"role": "admin"
|
|
||||||
}
|
|
||||||
}`),
|
|
||||||
RoleAttributePath: "attributes.role",
|
|
||||||
ExpectedResult: "admin",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
provider.info.RoleAttributePath = test.RoleAttributePath
|
|
||||||
t.Run(test.Name, func(t *testing.T) {
|
|
||||||
actualResult, err := provider.searchJSONForStringAttr(test.RoleAttributePath, test.UserInfoJSONResponse)
|
|
||||||
if test.ExpectedError == "" {
|
|
||||||
require.NoError(t, err, "Testing case %q", test.Name)
|
|
||||||
} else {
|
|
||||||
require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name)
|
|
||||||
}
|
|
||||||
require.Equal(t, test.ExpectedResult, actualResult)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
|
func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
|
||||||
provider := NewGenericOAuthProvider(&social.OAuthInfo{
|
provider := NewGenericOAuthProvider(&social.OAuthInfo{
|
||||||
EmailAttributePath: "email",
|
EmailAttributePath: "email",
|
||||||
|
@ -22,6 +22,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/org"
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
"github.com/grafana/grafana/pkg/services/ssosettings"
|
"github.com/grafana/grafana/pkg/services/ssosettings"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/grafana/grafana/pkg/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SocialBase struct {
|
type SocialBase struct {
|
||||||
@ -112,13 +113,13 @@ func (s *SocialBase) extractRoleAndAdmin(rawJSON []byte, groups []string) (org.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SocialBase) searchRole(rawJSON []byte, groups []string) (org.RoleType, bool) {
|
func (s *SocialBase) searchRole(rawJSON []byte, groups []string) (org.RoleType, bool) {
|
||||||
role, err := s.searchJSONForStringAttr(s.info.RoleAttributePath, rawJSON)
|
role, err := util.SearchJSONForStringAttr(s.info.RoleAttributePath, rawJSON)
|
||||||
if err == nil && role != "" {
|
if err == nil && role != "" {
|
||||||
return getRoleFromSearch(role)
|
return getRoleFromSearch(role)
|
||||||
}
|
}
|
||||||
|
|
||||||
if groupBytes, err := json.Marshal(groupStruct{groups}); err == nil {
|
if groupBytes, err := json.Marshal(groupStruct{groups}); err == nil {
|
||||||
role, err := s.searchJSONForStringAttr(s.info.RoleAttributePath, groupBytes)
|
role, err := util.SearchJSONForStringAttr(s.info.RoleAttributePath, groupBytes)
|
||||||
if err == nil && role != "" {
|
if err == nil && role != "" {
|
||||||
return getRoleFromSearch(role)
|
return getRoleFromSearch(role)
|
||||||
}
|
}
|
||||||
|
@ -33,7 +33,7 @@ func newService(cfg *setting.Cfg, remoteCache *remotecache.RemoteCache) *AuthSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthService) init() error {
|
func (s *AuthService) init() error {
|
||||||
if !s.Cfg.JWTAuthEnabled {
|
if !s.Cfg.JWTAuth.Enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,7 +75,7 @@ func TestVerifyUsingPKIXPublicKeyFile(t *testing.T) {
|
|||||||
assert.Equal(t, verifiedClaims["sub"], subject)
|
assert.Equal(t, verifiedClaims["sub"], subject)
|
||||||
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
|
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cfg.JWTAuthKeyID = publicKeyID
|
cfg.JWTAuth.KeyID = publicKeyID
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,7 +94,7 @@ func TestVerifyUsingJWKSetFile(t *testing.T) {
|
|||||||
require.NoError(t, json.NewEncoder(file).Encode(jwksPublic))
|
require.NoError(t, json.NewEncoder(file).Encode(jwksPublic))
|
||||||
require.NoError(t, file.Close())
|
require.NoError(t, file.Close())
|
||||||
|
|
||||||
cfg.JWTAuthJWKSetFile = file.Name()
|
cfg.JWTAuth.JWKSetFile = file.Name()
|
||||||
}
|
}
|
||||||
|
|
||||||
scenario(t, "verifies a token signed with a key from the set", func(t *testing.T, sc scenarioContext) {
|
scenario(t, "verifies a token signed with a key from the set", func(t *testing.T, sc scenarioContext) {
|
||||||
@ -123,18 +123,18 @@ func TestVerifyUsingJWKSetURL(t *testing.T) {
|
|||||||
var err error
|
var err error
|
||||||
|
|
||||||
_, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) {
|
_, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) {
|
||||||
cfg.JWTAuthJWKSetURL = "https://example.com/.well-known/jwks.json"
|
cfg.JWTAuth.JWKSetURL = "https://example.com/.well-known/jwks.json"
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) {
|
_, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) {
|
||||||
cfg.JWTAuthJWKSetURL = "http://example.com/.well-known/jwks.json"
|
cfg.JWTAuth.JWKSetURL = "http://example.com/.well-known/jwks.json"
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) {
|
_, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) {
|
||||||
cfg.Env = setting.Prod
|
cfg.Env = setting.Prod
|
||||||
cfg.JWTAuthJWKSetURL = "http://example.com/.well-known/jwks.json"
|
cfg.JWTAuth.JWKSetURL = "http://example.com/.well-known/jwks.json"
|
||||||
})
|
})
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
})
|
})
|
||||||
@ -185,7 +185,7 @@ func TestCachingJWKHTTPResponse(t *testing.T) {
|
|||||||
assert.Equal(t, 1, *sc.reqCount)
|
assert.Equal(t, 1, *sc.reqCount)
|
||||||
}, func(t *testing.T, cfg *setting.Cfg) {
|
}, func(t *testing.T, cfg *setting.Cfg) {
|
||||||
// Arbitrary high value, several times what the test should take.
|
// Arbitrary high value, several times what the test should take.
|
||||||
cfg.JWTAuthCacheTTL = time.Minute
|
cfg.JWTAuth.CacheTTL = time.Minute
|
||||||
})
|
})
|
||||||
|
|
||||||
jwkCachingScenario(t, "does not cache the response when TTL is zero", func(t *testing.T, sc cachingScenarioContext) {
|
jwkCachingScenario(t, "does not cache the response when TTL is zero", func(t *testing.T, sc cachingScenarioContext) {
|
||||||
@ -196,7 +196,7 @@ func TestCachingJWKHTTPResponse(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, 2, *sc.reqCount)
|
assert.Equal(t, 2, *sc.reqCount)
|
||||||
}, func(t *testing.T, cfg *setting.Cfg) {
|
}, func(t *testing.T, cfg *setting.Cfg) {
|
||||||
cfg.JWTAuthCacheTTL = 0
|
cfg.JWTAuth.CacheTTL = 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,7 +221,7 @@ func TestClaimValidation(t *testing.T) {
|
|||||||
_, err = sc.authJWTSvc.Verify(sc.ctx, tokenInvalid)
|
_, err = sc.authJWTSvc.Verify(sc.ctx, tokenInvalid)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
|
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
|
||||||
cfg.JWTAuthExpectClaims = `{"iss": "http://foo"}`
|
cfg.JWTAuth.ExpectClaims = `{"iss": "http://foo"}`
|
||||||
})
|
})
|
||||||
|
|
||||||
scenario(t, "validates sub field for equality", func(t *testing.T, sc scenarioContext) {
|
scenario(t, "validates sub field for equality", func(t *testing.T, sc scenarioContext) {
|
||||||
@ -236,7 +236,7 @@ func TestClaimValidation(t *testing.T) {
|
|||||||
_, err = sc.authJWTSvc.Verify(sc.ctx, tokenInvalid)
|
_, err = sc.authJWTSvc.Verify(sc.ctx, tokenInvalid)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
|
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
|
||||||
cfg.JWTAuthExpectClaims = `{"sub": "foo"}`
|
cfg.JWTAuth.ExpectClaims = `{"sub": "foo"}`
|
||||||
})
|
})
|
||||||
|
|
||||||
scenario(t, "validates aud field for inclusion", func(t *testing.T, sc scenarioContext) {
|
scenario(t, "validates aud field for inclusion", func(t *testing.T, sc scenarioContext) {
|
||||||
@ -257,7 +257,7 @@ func TestClaimValidation(t *testing.T) {
|
|||||||
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"baz"}}, nil))
|
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"baz"}}, nil))
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
|
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
|
||||||
cfg.JWTAuthExpectClaims = `{"aud": ["foo", "bar"]}`
|
cfg.JWTAuth.ExpectClaims = `{"aud": ["foo", "bar"]}`
|
||||||
})
|
})
|
||||||
|
|
||||||
scenario(t, "validates non-registered (custom) claims for equality", func(t *testing.T, sc scenarioContext) {
|
scenario(t, "validates non-registered (custom) claims for equality", func(t *testing.T, sc scenarioContext) {
|
||||||
@ -278,7 +278,7 @@ func TestClaimValidation(t *testing.T) {
|
|||||||
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, map[string]any{"my-number": 123}, nil))
|
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, map[string]any{"my-number": 123}, nil))
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
|
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
|
||||||
cfg.JWTAuthExpectClaims = `{"my-str": "foo", "my-number": 123}`
|
cfg.JWTAuth.ExpectClaims = `{"my-str": "foo", "my-number": 123}`
|
||||||
})
|
})
|
||||||
|
|
||||||
scenario(t, "validates exp claim of the token", func(t *testing.T, sc scenarioContext) {
|
scenario(t, "validates exp claim of the token", func(t *testing.T, sc scenarioContext) {
|
||||||
@ -323,7 +323,7 @@ func jwkHTTPScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...configur
|
|||||||
t.Cleanup(ts.Close)
|
t.Cleanup(ts.Close)
|
||||||
|
|
||||||
configure := func(t *testing.T, cfg *setting.Cfg) {
|
configure := func(t *testing.T, cfg *setting.Cfg) {
|
||||||
cfg.JWTAuthJWKSetURL = ts.URL
|
cfg.JWTAuth.JWKSetURL = ts.URL
|
||||||
}
|
}
|
||||||
runner := scenarioRunner(func(t *testing.T, sc scenarioContext) {
|
runner := scenarioRunner(func(t *testing.T, sc scenarioContext) {
|
||||||
keySet := sc.authJWTSvc.keySet.(*keySetHTTP)
|
keySet := sc.authJWTSvc.keySet.(*keySetHTTP)
|
||||||
@ -355,8 +355,8 @@ func jwkCachingScenario(t *testing.T, desc string, fn cachingScenarioFunc, cbs .
|
|||||||
t.Cleanup(ts.Close)
|
t.Cleanup(ts.Close)
|
||||||
|
|
||||||
configure := func(t *testing.T, cfg *setting.Cfg) {
|
configure := func(t *testing.T, cfg *setting.Cfg) {
|
||||||
cfg.JWTAuthJWKSetURL = ts.URL
|
cfg.JWTAuth.JWKSetURL = ts.URL
|
||||||
cfg.JWTAuthCacheTTL = time.Hour
|
cfg.JWTAuth.CacheTTL = time.Hour
|
||||||
}
|
}
|
||||||
runner := scenarioRunner(func(t *testing.T, sc scenarioContext) {
|
runner := scenarioRunner(func(t *testing.T, sc scenarioContext) {
|
||||||
keySet := sc.authJWTSvc.keySet.(*keySetHTTP)
|
keySet := sc.authJWTSvc.keySet.(*keySetHTTP)
|
||||||
@ -397,8 +397,8 @@ func initAuthService(t *testing.T, cbs ...configureFunc) (*AuthService, error) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
cfg := setting.NewCfg()
|
cfg := setting.NewCfg()
|
||||||
cfg.JWTAuthEnabled = true
|
cfg.JWTAuth.Enabled = true
|
||||||
cfg.JWTAuthExpectClaims = "{}"
|
cfg.JWTAuth.ExpectClaims = "{}"
|
||||||
|
|
||||||
for _, cb := range cbs {
|
for _, cb := range cbs {
|
||||||
cb(t, cfg)
|
cb(t, cfg)
|
||||||
@ -442,5 +442,5 @@ func configurePKIXPublicKeyFile(t *testing.T, cfg *setting.Cfg) {
|
|||||||
}))
|
}))
|
||||||
require.NoError(t, file.Close())
|
require.NoError(t, file.Close())
|
||||||
|
|
||||||
cfg.JWTAuthKeyFile = file.Name()
|
cfg.JWTAuth.KeyFile = file.Name()
|
||||||
}
|
}
|
||||||
|
@ -2,9 +2,11 @@ package jwt
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type JWTClaims map[string]any
|
type JWTClaims util.DynMap
|
||||||
|
|
||||||
type JWTService interface {
|
type JWTService interface {
|
||||||
Verify(ctx context.Context, strToken string) (JWTClaims, error)
|
Verify(ctx context.Context, strToken string) (JWTClaims, error)
|
||||||
|
@ -49,13 +49,13 @@ type keySetHTTP struct {
|
|||||||
|
|
||||||
func (s *AuthService) checkKeySetConfiguration() error {
|
func (s *AuthService) checkKeySetConfiguration() error {
|
||||||
var count int
|
var count int
|
||||||
if s.Cfg.JWTAuthKeyFile != "" {
|
if s.Cfg.JWTAuth.KeyFile != "" {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
if s.Cfg.JWTAuthJWKSetFile != "" {
|
if s.Cfg.JWTAuth.JWKSetFile != "" {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
if s.Cfg.JWTAuthJWKSetURL != "" {
|
if s.Cfg.JWTAuth.JWKSetURL != "" {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,7 +75,7 @@ func (s *AuthService) initKeySet() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if keyFilePath := s.Cfg.JWTAuthKeyFile; keyFilePath != "" {
|
if keyFilePath := s.Cfg.JWTAuth.KeyFile; keyFilePath != "" {
|
||||||
// nolint:gosec
|
// nolint:gosec
|
||||||
// We can ignore the gosec G304 warning on this one because `fileName` comes from grafana configuration file
|
// We can ignore the gosec G304 warning on this one because `fileName` comes from grafana configuration file
|
||||||
file, err := os.Open(keyFilePath)
|
file, err := os.Open(keyFilePath)
|
||||||
@ -125,10 +125,10 @@ func (s *AuthService) initKeySet() error {
|
|||||||
|
|
||||||
s.keySet = &keySetJWKS{
|
s.keySet = &keySetJWKS{
|
||||||
jose.JSONWebKeySet{
|
jose.JSONWebKeySet{
|
||||||
Keys: []jose.JSONWebKey{{Key: key, KeyID: s.Cfg.JWTAuthKeyID}},
|
Keys: []jose.JSONWebKey{{Key: key, KeyID: s.Cfg.JWTAuth.KeyID}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if keyFilePath := s.Cfg.JWTAuthJWKSetFile; keyFilePath != "" {
|
} else if keyFilePath := s.Cfg.JWTAuth.JWKSetFile; keyFilePath != "" {
|
||||||
// nolint:gosec
|
// nolint:gosec
|
||||||
// We can ignore the gosec G304 warning on this one because `fileName` comes from grafana configuration file
|
// We can ignore the gosec G304 warning on this one because `fileName` comes from grafana configuration file
|
||||||
file, err := os.Open(keyFilePath)
|
file, err := os.Open(keyFilePath)
|
||||||
@ -147,7 +147,7 @@ func (s *AuthService) initKeySet() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.keySet = &keySetJWKS{jwks}
|
s.keySet = &keySetJWKS{jwks}
|
||||||
} else if urlStr := s.Cfg.JWTAuthJWKSetURL; urlStr != "" {
|
} else if urlStr := s.Cfg.JWTAuth.JWKSetURL; urlStr != "" {
|
||||||
urlParsed, err := url.Parse(urlStr)
|
urlParsed, err := url.Parse(urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -176,7 +176,7 @@ func (s *AuthService) initKeySet() error {
|
|||||||
Timeout: time.Second * 30,
|
Timeout: time.Second * 30,
|
||||||
},
|
},
|
||||||
cacheKey: fmt.Sprintf("auth-jwt:jwk-%s", urlStr),
|
cacheKey: fmt.Sprintf("auth-jwt:jwk-%s", urlStr),
|
||||||
cacheExpiration: s.Cfg.JWTAuthCacheTTL,
|
cacheExpiration: s.Cfg.JWTAuth.CacheTTL,
|
||||||
cache: s.RemoteCache,
|
cache: s.RemoteCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *AuthService) initClaimExpectations() error {
|
func (s *AuthService) initClaimExpectations() error {
|
||||||
if err := json.Unmarshal([]byte(s.Cfg.JWTAuthExpectClaims), &s.expect); err != nil {
|
if err := json.Unmarshal([]byte(s.Cfg.JWTAuth.ExpectClaims), &s.expect); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,7 +131,7 @@ func ProvideService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.cfg.JWTAuthEnabled {
|
if s.cfg.JWTAuth.Enabled {
|
||||||
s.RegisterClient(clients.ProvideJWT(jwtService, cfg))
|
s.RegisterClient(clients.ProvideJWT(jwtService, cfg))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ func (s *Service) getUsageStats(ctx context.Context) (map[string]any, error) {
|
|||||||
authTypes["ldap"] = s.cfg.LDAPAuthEnabled
|
authTypes["ldap"] = s.cfg.LDAPAuthEnabled
|
||||||
authTypes["auth_proxy"] = s.cfg.AuthProxyEnabled
|
authTypes["auth_proxy"] = s.cfg.AuthProxyEnabled
|
||||||
authTypes["anonymous"] = s.cfg.AnonymousEnabled
|
authTypes["anonymous"] = s.cfg.AnonymousEnabled
|
||||||
authTypes["jwt"] = s.cfg.JWTAuthEnabled
|
authTypes["jwt"] = s.cfg.JWTAuth.Enabled
|
||||||
authTypes["grafana_password"] = !s.cfg.DisableLogin
|
authTypes["grafana_password"] = !s.cfg.DisableLogin
|
||||||
authTypes["login_form"] = !s.cfg.DisableLoginForm
|
authTypes["login_form"] = !s.cfg.DisableLoginForm
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ func TestService_getUsageStats(t *testing.T) {
|
|||||||
svc.cfg.DisableLogin = false
|
svc.cfg.DisableLogin = false
|
||||||
svc.cfg.BasicAuthEnabled = true
|
svc.cfg.BasicAuthEnabled = true
|
||||||
svc.cfg.AuthProxyEnabled = true
|
svc.cfg.AuthProxyEnabled = true
|
||||||
svc.cfg.JWTAuthEnabled = true
|
svc.cfg.JWTAuth.Enabled = true
|
||||||
svc.cfg.LDAPAuthEnabled = true
|
svc.cfg.LDAPAuthEnabled = true
|
||||||
svc.cfg.EditorsCanAdmin = true
|
svc.cfg.EditorsCanAdmin = true
|
||||||
svc.cfg.ViewersCanEdit = true
|
svc.cfg.ViewersCanEdit = true
|
||||||
|
@ -2,13 +2,9 @@ package clients
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jmespath/go-jmespath"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/services/auth"
|
"github.com/grafana/grafana/pkg/services/auth"
|
||||||
authJWT "github.com/grafana/grafana/pkg/services/auth/jwt"
|
authJWT "github.com/grafana/grafana/pkg/services/auth/jwt"
|
||||||
@ -16,6 +12,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/login"
|
"github.com/grafana/grafana/pkg/services/login"
|
||||||
"github.com/grafana/grafana/pkg/services/org"
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/grafana/grafana/pkg/util"
|
||||||
"github.com/grafana/grafana/pkg/util/errutil"
|
"github.com/grafana/grafana/pkg/util/errutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -73,15 +70,16 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi
|
|||||||
SyncUser: true,
|
SyncUser: true,
|
||||||
FetchSyncedUser: true,
|
FetchSyncedUser: true,
|
||||||
SyncPermissions: true,
|
SyncPermissions: true,
|
||||||
SyncOrgRoles: !s.cfg.JWTAuthSkipOrgRoleSync,
|
SyncOrgRoles: !s.cfg.JWTAuth.SkipOrgRoleSync,
|
||||||
AllowSignUp: s.cfg.JWTAuthAutoSignUp,
|
AllowSignUp: s.cfg.JWTAuth.AutoSignUp,
|
||||||
|
SyncTeams: s.cfg.JWTAuth.GroupsAttributePath != "",
|
||||||
}}
|
}}
|
||||||
|
|
||||||
if key := s.cfg.JWTAuthUsernameClaim; key != "" {
|
if key := s.cfg.JWTAuth.UsernameClaim; key != "" {
|
||||||
id.Login, _ = claims[key].(string)
|
id.Login, _ = claims[key].(string)
|
||||||
id.ClientParams.LookUpParams.Login = &id.Login
|
id.ClientParams.LookUpParams.Login = &id.Login
|
||||||
}
|
}
|
||||||
if key := s.cfg.JWTAuthEmailClaim; key != "" {
|
if key := s.cfg.JWTAuth.EmailClaim; key != "" {
|
||||||
id.Email, _ = claims[key].(string)
|
id.Email, _ = claims[key].(string)
|
||||||
id.ClientParams.LookUpParams.Email = &id.Email
|
id.ClientParams.LookUpParams.Email = &id.Email
|
||||||
}
|
}
|
||||||
@ -91,16 +89,16 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi
|
|||||||
}
|
}
|
||||||
|
|
||||||
orgRoles, isGrafanaAdmin, err := getRoles(s.cfg, func() (org.RoleType, *bool, error) {
|
orgRoles, isGrafanaAdmin, err := getRoles(s.cfg, func() (org.RoleType, *bool, error) {
|
||||||
if s.cfg.JWTAuthSkipOrgRoleSync {
|
if s.cfg.JWTAuth.SkipOrgRoleSync {
|
||||||
return "", nil, nil
|
return "", nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
role, grafanaAdmin := s.extractRoleAndAdmin(claims)
|
role, grafanaAdmin := s.extractRoleAndAdmin(claims)
|
||||||
if s.cfg.JWTAuthRoleAttributeStrict && !role.IsValid() {
|
if s.cfg.JWTAuth.RoleAttributeStrict && !role.IsValid() {
|
||||||
return "", nil, errJWTInvalidRole.Errorf("invalid role claim in JWT: %s", role)
|
return "", nil, errJWTInvalidRole.Errorf("invalid role claim in JWT: %s", role)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.cfg.JWTAuthAllowAssignGrafanaAdmin {
|
if !s.cfg.JWTAuth.AllowAssignGrafanaAdmin {
|
||||||
return role, nil, nil
|
return role, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,6 +112,11 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi
|
|||||||
id.OrgRoles = orgRoles
|
id.OrgRoles = orgRoles
|
||||||
id.IsGrafanaAdmin = isGrafanaAdmin
|
id.IsGrafanaAdmin = isGrafanaAdmin
|
||||||
|
|
||||||
|
id.Groups, err = s.extractGroups(claims)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if id.Login == "" && id.Email == "" {
|
if id.Login == "" && id.Email == "" {
|
||||||
s.log.FromContext(ctx).Debug("Failed to get an authentication claim from JWT",
|
s.log.FromContext(ctx).Debug("Failed to get an authentication claim from JWT",
|
||||||
"login", id.Login, "email", id.Email)
|
"login", id.Login, "email", id.Email)
|
||||||
@ -126,7 +129,7 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi
|
|||||||
// remove sensitive query param
|
// remove sensitive query param
|
||||||
// avoid JWT URL login passing auth_token in URL
|
// avoid JWT URL login passing auth_token in URL
|
||||||
func (s *JWT) stripSensitiveParam(httpRequest *http.Request) {
|
func (s *JWT) stripSensitiveParam(httpRequest *http.Request) {
|
||||||
if s.cfg.JWTAuthURLLogin {
|
if s.cfg.JWTAuth.URLLogin {
|
||||||
params := httpRequest.URL.Query()
|
params := httpRequest.URL.Query()
|
||||||
if params.Has(authQueryParamName) {
|
if params.Has(authQueryParamName) {
|
||||||
params.Del(authQueryParamName)
|
params.Del(authQueryParamName)
|
||||||
@ -137,8 +140,8 @@ func (s *JWT) stripSensitiveParam(httpRequest *http.Request) {
|
|||||||
|
|
||||||
// retrieveToken retrieves the JWT token from the request.
|
// retrieveToken retrieves the JWT token from the request.
|
||||||
func (s *JWT) retrieveToken(httpRequest *http.Request) string {
|
func (s *JWT) retrieveToken(httpRequest *http.Request) string {
|
||||||
jwtToken := httpRequest.Header.Get(s.cfg.JWTAuthHeaderName)
|
jwtToken := httpRequest.Header.Get(s.cfg.JWTAuth.HeaderName)
|
||||||
if jwtToken == "" && s.cfg.JWTAuthURLLogin {
|
if jwtToken == "" && s.cfg.JWTAuth.URLLogin {
|
||||||
jwtToken = httpRequest.URL.Query().Get("auth_token")
|
jwtToken = httpRequest.URL.Query().Get("auth_token")
|
||||||
}
|
}
|
||||||
// Strip the 'Bearer' prefix if it exists.
|
// Strip the 'Bearer' prefix if it exists.
|
||||||
@ -146,7 +149,7 @@ func (s *JWT) retrieveToken(httpRequest *http.Request) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *JWT) Test(ctx context.Context, r *authn.Request) bool {
|
func (s *JWT) Test(ctx context.Context, r *authn.Request) bool {
|
||||||
if !s.cfg.JWTAuthEnabled || s.cfg.JWTAuthHeaderName == "" {
|
if !s.cfg.JWTAuth.Enabled || s.cfg.JWTAuth.HeaderName == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,11 +174,11 @@ func (s *JWT) Priority() uint {
|
|||||||
const roleGrafanaAdmin = "GrafanaAdmin"
|
const roleGrafanaAdmin = "GrafanaAdmin"
|
||||||
|
|
||||||
func (s *JWT) extractRoleAndAdmin(claims map[string]any) (org.RoleType, bool) {
|
func (s *JWT) extractRoleAndAdmin(claims map[string]any) (org.RoleType, bool) {
|
||||||
if s.cfg.JWTAuthRoleAttributePath == "" {
|
if s.cfg.JWTAuth.RoleAttributePath == "" {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
role, err := searchClaimsForStringAttr(s.cfg.JWTAuthRoleAttributePath, claims)
|
role, err := util.SearchJSONForStringAttr(s.cfg.JWTAuth.RoleAttributePath, claims)
|
||||||
if err != nil || role == "" {
|
if err != nil || role == "" {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@ -186,33 +189,10 @@ func (s *JWT) extractRoleAndAdmin(claims map[string]any) (org.RoleType, bool) {
|
|||||||
return org.RoleType(role), false
|
return org.RoleType(role), false
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchClaimsForStringAttr(attributePath string, claims map[string]any) (string, error) {
|
func (s *JWT) extractGroups(claims map[string]any) ([]string, error) {
|
||||||
val, err := searchClaimsForAttr(attributePath, claims)
|
if s.cfg.JWTAuth.GroupsAttributePath == "" {
|
||||||
if err != nil {
|
return []string{}, nil
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
strVal, ok := val.(string)
|
return util.SearchJSONForStringSliceAttr(s.cfg.JWTAuth.GroupsAttributePath, claims)
|
||||||
if ok {
|
|
||||||
return strVal, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func searchClaimsForAttr(attributePath string, claims map[string]any) (any, error) {
|
|
||||||
if attributePath == "" {
|
|
||||||
return "", errors.New("no attribute path specified")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(claims) == 0 {
|
|
||||||
return "", errors.New("empty claims provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
val, err := jmespath.Search(attributePath, claims)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to search claims with provided path: %q: %w", attributePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return val, nil
|
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/authn"
|
"github.com/grafana/grafana/pkg/services/authn"
|
||||||
"github.com/grafana/grafana/pkg/services/login"
|
"github.com/grafana/grafana/pkg/services/login"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/grafana/grafana/pkg/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func stringPtr(s string) *string {
|
func stringPtr(s string) *string {
|
||||||
@ -22,72 +23,153 @@ func stringPtr(s string) *string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthenticateJWT(t *testing.T) {
|
func TestAuthenticateJWT(t *testing.T) {
|
||||||
jwtService := &jwt.FakeJWTService{
|
t.Parallel()
|
||||||
VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) {
|
|
||||||
return jwt.JWTClaims{
|
|
||||||
"sub": "1234567890",
|
|
||||||
"email": "eai.doe@cor.po",
|
|
||||||
"preferred_username": "eai-doe",
|
|
||||||
"name": "Eai Doe",
|
|
||||||
"roles": "Admin",
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
jwtHeaderName := "X-Forwarded-User"
|
jwtHeaderName := "X-Forwarded-User"
|
||||||
wantID := &authn.Identity{
|
|
||||||
OrgID: 0,
|
testCases := []struct {
|
||||||
OrgName: "",
|
name string
|
||||||
OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleAdmin},
|
wantID *authn.Identity
|
||||||
ID: "",
|
verifyProvider func(context.Context, string) (jwt.JWTClaims, error)
|
||||||
Login: "eai-doe",
|
cfg *setting.Cfg
|
||||||
Name: "Eai Doe",
|
}{
|
||||||
Email: "eai.doe@cor.po",
|
{
|
||||||
IsGrafanaAdmin: boolPtr(false),
|
name: "Valid Use case with group path",
|
||||||
AuthenticatedBy: login.JWTModule,
|
wantID: &authn.Identity{
|
||||||
AuthID: "1234567890",
|
OrgID: 0,
|
||||||
IsDisabled: false,
|
OrgName: "",
|
||||||
HelpFlags1: 0,
|
OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleAdmin},
|
||||||
ClientParams: authn.ClientParams{
|
Groups: []string{"foo", "bar"},
|
||||||
SyncUser: true,
|
ID: "",
|
||||||
AllowSignUp: true,
|
Login: "eai-doe",
|
||||||
FetchSyncedUser: true,
|
Name: "Eai Doe",
|
||||||
SyncOrgRoles: true,
|
Email: "eai.doe@cor.po",
|
||||||
SyncPermissions: true,
|
IsGrafanaAdmin: boolPtr(false),
|
||||||
LookUpParams: login.UserLookupParams{
|
AuthenticatedBy: login.JWTModule,
|
||||||
UserID: nil,
|
AuthID: "1234567890",
|
||||||
Email: stringPtr("eai.doe@cor.po"),
|
IsDisabled: false,
|
||||||
Login: stringPtr("eai-doe"),
|
HelpFlags1: 0,
|
||||||
|
ClientParams: authn.ClientParams{
|
||||||
|
SyncUser: true,
|
||||||
|
AllowSignUp: true,
|
||||||
|
FetchSyncedUser: true,
|
||||||
|
SyncOrgRoles: true,
|
||||||
|
SyncPermissions: true,
|
||||||
|
SyncTeams: true,
|
||||||
|
LookUpParams: login.UserLookupParams{
|
||||||
|
UserID: nil,
|
||||||
|
Email: stringPtr("eai.doe@cor.po"),
|
||||||
|
Login: stringPtr("eai-doe"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
verifyProvider: func(context.Context, string) (jwt.JWTClaims, error) {
|
||||||
|
return jwt.JWTClaims{
|
||||||
|
"sub": "1234567890",
|
||||||
|
"email": "eai.doe@cor.po",
|
||||||
|
"preferred_username": "eai-doe",
|
||||||
|
"name": "Eai Doe",
|
||||||
|
"roles": "Admin",
|
||||||
|
"groups": []string{"foo", "bar"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
cfg: &setting.Cfg{
|
||||||
|
JWTAuth: setting.AuthJWTSettings{
|
||||||
|
Enabled: true,
|
||||||
|
HeaderName: jwtHeaderName,
|
||||||
|
EmailClaim: "email",
|
||||||
|
UsernameClaim: "preferred_username",
|
||||||
|
AutoSignUp: true,
|
||||||
|
AllowAssignGrafanaAdmin: true,
|
||||||
|
RoleAttributeStrict: true,
|
||||||
|
RoleAttributePath: "roles",
|
||||||
|
GroupsAttributePath: "groups[]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid Use case without group path",
|
||||||
|
wantID: &authn.Identity{
|
||||||
|
OrgID: 0,
|
||||||
|
OrgName: "",
|
||||||
|
OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleAdmin},
|
||||||
|
ID: "",
|
||||||
|
Login: "eai-doe",
|
||||||
|
Groups: []string{},
|
||||||
|
Name: "Eai Doe",
|
||||||
|
Email: "eai.doe@cor.po",
|
||||||
|
IsGrafanaAdmin: boolPtr(false),
|
||||||
|
AuthenticatedBy: login.JWTModule,
|
||||||
|
AuthID: "1234567890",
|
||||||
|
IsDisabled: false,
|
||||||
|
HelpFlags1: 0,
|
||||||
|
ClientParams: authn.ClientParams{
|
||||||
|
SyncUser: true,
|
||||||
|
AllowSignUp: true,
|
||||||
|
FetchSyncedUser: true,
|
||||||
|
SyncOrgRoles: true,
|
||||||
|
SyncPermissions: true,
|
||||||
|
SyncTeams: false,
|
||||||
|
LookUpParams: login.UserLookupParams{
|
||||||
|
UserID: nil,
|
||||||
|
Email: stringPtr("eai.doe@cor.po"),
|
||||||
|
Login: stringPtr("eai-doe"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
verifyProvider: func(context.Context, string) (jwt.JWTClaims, error) {
|
||||||
|
return jwt.JWTClaims{
|
||||||
|
"sub": "1234567890",
|
||||||
|
"email": "eai.doe@cor.po",
|
||||||
|
"preferred_username": "eai-doe",
|
||||||
|
"name": "Eai Doe",
|
||||||
|
"roles": "Admin",
|
||||||
|
"groups": []string{"foo", "bar"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
cfg: &setting.Cfg{
|
||||||
|
JWTAuth: setting.AuthJWTSettings{
|
||||||
|
Enabled: true,
|
||||||
|
HeaderName: jwtHeaderName,
|
||||||
|
EmailClaim: "email",
|
||||||
|
UsernameClaim: "preferred_username",
|
||||||
|
AutoSignUp: true,
|
||||||
|
AllowAssignGrafanaAdmin: true,
|
||||||
|
RoleAttributeStrict: true,
|
||||||
|
RoleAttributePath: "roles",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &setting.Cfg{
|
for _, tc := range testCases {
|
||||||
JWTAuthEnabled: true,
|
tc := tc
|
||||||
JWTAuthHeaderName: jwtHeaderName,
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
JWTAuthEmailClaim: "email",
|
t.Parallel()
|
||||||
JWTAuthUsernameClaim: "preferred_username",
|
jwtService := &jwt.FakeJWTService{
|
||||||
JWTAuthAutoSignUp: true,
|
VerifyProvider: tc.verifyProvider,
|
||||||
JWTAuthAllowAssignGrafanaAdmin: true,
|
}
|
||||||
JWTAuthRoleAttributeStrict: true,
|
|
||||||
JWTAuthRoleAttributePath: "roles",
|
|
||||||
}
|
|
||||||
jwtClient := ProvideJWT(jwtService, cfg)
|
|
||||||
validHTTPReq := &http.Request{
|
|
||||||
Header: map[string][]string{
|
|
||||||
jwtHeaderName: {"sample-token"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := jwtClient.Authenticate(context.Background(), &authn.Request{
|
jwtClient := ProvideJWT(jwtService, tc.cfg)
|
||||||
OrgID: 1,
|
validHTTPReq := &http.Request{
|
||||||
HTTPRequest: validHTTPReq,
|
Header: map[string][]string{
|
||||||
Resp: nil,
|
jwtHeaderName: {"sample-token"}},
|
||||||
})
|
}
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.EqualValues(t, wantID, id, fmt.Sprintf("%+v", id))
|
id, err := jwtClient.Authenticate(context.Background(), &authn.Request{
|
||||||
|
OrgID: 1,
|
||||||
|
HTTPRequest: validHTTPReq,
|
||||||
|
Resp: nil,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.EqualValues(t, tc.wantID, id, fmt.Sprintf("%+v", id))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestJWTClaimConfig(t *testing.T) {
|
func TestJWTClaimConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
jwtService := &jwt.FakeJWTService{
|
jwtService := &jwt.FakeJWTService{
|
||||||
VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) {
|
VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) {
|
||||||
return jwt.JWTClaims{
|
return jwt.JWTClaims{
|
||||||
@ -102,30 +184,19 @@ func TestJWTClaimConfig(t *testing.T) {
|
|||||||
|
|
||||||
jwtHeaderName := "X-Forwarded-User"
|
jwtHeaderName := "X-Forwarded-User"
|
||||||
|
|
||||||
cfg := &setting.Cfg{
|
|
||||||
JWTAuthEnabled: true,
|
|
||||||
JWTAuthHeaderName: jwtHeaderName,
|
|
||||||
JWTAuthAutoSignUp: true,
|
|
||||||
JWTAuthAllowAssignGrafanaAdmin: true,
|
|
||||||
JWTAuthRoleAttributeStrict: true,
|
|
||||||
JWTAuthRoleAttributePath: "roles",
|
|
||||||
}
|
|
||||||
|
|
||||||
// #nosec G101 -- This is a dummy/test token
|
// #nosec G101 -- This is a dummy/test token
|
||||||
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o"
|
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o"
|
||||||
|
|
||||||
type Dictionary map[string]any
|
|
||||||
|
|
||||||
type testCase struct {
|
type testCase struct {
|
||||||
desc string
|
desc string
|
||||||
claimsConfigurations []Dictionary
|
claimsConfigurations []util.DynMap
|
||||||
valid bool
|
valid bool
|
||||||
}
|
}
|
||||||
|
|
||||||
testCases := []testCase{
|
testCases := []testCase{
|
||||||
{
|
{
|
||||||
desc: "JWT configuration with email and username claims",
|
desc: "JWT configuration with email and username claims",
|
||||||
claimsConfigurations: []Dictionary{
|
claimsConfigurations: []util.DynMap{
|
||||||
{
|
{
|
||||||
"JWTAuthEmailClaim": true,
|
"JWTAuthEmailClaim": true,
|
||||||
"JWTAuthUsernameClaim": true,
|
"JWTAuthUsernameClaim": true,
|
||||||
@ -135,7 +206,7 @@ func TestJWTClaimConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
desc: "JWT configuration with email claim",
|
desc: "JWT configuration with email claim",
|
||||||
claimsConfigurations: []Dictionary{
|
claimsConfigurations: []util.DynMap{
|
||||||
{
|
{
|
||||||
"JWTAuthEmailClaim": true,
|
"JWTAuthEmailClaim": true,
|
||||||
"JWTAuthUsernameClaim": false,
|
"JWTAuthUsernameClaim": false,
|
||||||
@ -145,7 +216,7 @@ func TestJWTClaimConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
desc: "JWT configuration with username claim",
|
desc: "JWT configuration with username claim",
|
||||||
claimsConfigurations: []Dictionary{
|
claimsConfigurations: []util.DynMap{
|
||||||
{
|
{
|
||||||
"JWTAuthEmailClaim": false,
|
"JWTAuthEmailClaim": false,
|
||||||
"JWTAuthUsernameClaim": true,
|
"JWTAuthUsernameClaim": true,
|
||||||
@ -155,7 +226,7 @@ func TestJWTClaimConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
desc: "JWT configuration without email and username claims",
|
desc: "JWT configuration without email and username claims",
|
||||||
claimsConfigurations: []Dictionary{
|
claimsConfigurations: []util.DynMap{
|
||||||
{
|
{
|
||||||
"JWTAuthEmailClaim": false,
|
"JWTAuthEmailClaim": false,
|
||||||
"JWTAuthUsernameClaim": false,
|
"JWTAuthUsernameClaim": false,
|
||||||
@ -166,39 +237,53 @@ func TestJWTClaimConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
t.Run(tc.desc, func(t *testing.T) {
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := &setting.Cfg{
|
||||||
|
JWTAuth: setting.AuthJWTSettings{
|
||||||
|
Enabled: true,
|
||||||
|
HeaderName: jwtHeaderName,
|
||||||
|
AutoSignUp: true,
|
||||||
|
AllowAssignGrafanaAdmin: true,
|
||||||
|
RoleAttributeStrict: true,
|
||||||
|
RoleAttributePath: "roles",
|
||||||
|
},
|
||||||
|
}
|
||||||
for _, claims := range tc.claimsConfigurations {
|
for _, claims := range tc.claimsConfigurations {
|
||||||
cfg.JWTAuthEmailClaim = ""
|
cfg.JWTAuth.EmailClaim = ""
|
||||||
cfg.JWTAuthUsernameClaim = ""
|
cfg.JWTAuth.UsernameClaim = ""
|
||||||
|
|
||||||
if claims["JWTAuthEmailClaim"] == true {
|
if claims["JWTAuthEmailClaim"] == true {
|
||||||
cfg.JWTAuthEmailClaim = "email"
|
cfg.JWTAuth.EmailClaim = "email"
|
||||||
}
|
}
|
||||||
if claims["JWTAuthUsernameClaim"] == true {
|
if claims["JWTAuthUsernameClaim"] == true {
|
||||||
cfg.JWTAuthUsernameClaim = "preferred_username"
|
cfg.JWTAuth.UsernameClaim = "preferred_username"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
httpReq := &http.Request{
|
||||||
|
URL: &url.URL{RawQuery: "auth_token=" + token},
|
||||||
|
Header: map[string][]string{
|
||||||
|
jwtHeaderName: {token}},
|
||||||
|
}
|
||||||
|
jwtClient := ProvideJWT(jwtService, cfg)
|
||||||
|
_, err := jwtClient.Authenticate(context.Background(), &authn.Request{
|
||||||
|
OrgID: 1,
|
||||||
|
HTTPRequest: httpReq,
|
||||||
|
Resp: nil,
|
||||||
|
})
|
||||||
|
if tc.valid {
|
||||||
|
require.NoError(t, err)
|
||||||
|
} else {
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
httpReq := &http.Request{
|
|
||||||
URL: &url.URL{RawQuery: "auth_token=" + token},
|
|
||||||
Header: map[string][]string{
|
|
||||||
jwtHeaderName: {token}},
|
|
||||||
}
|
|
||||||
jwtClient := ProvideJWT(jwtService, cfg)
|
|
||||||
_, err := jwtClient.Authenticate(context.Background(), &authn.Request{
|
|
||||||
OrgID: 1,
|
|
||||||
HTTPRequest: httpReq,
|
|
||||||
Resp: nil,
|
|
||||||
})
|
|
||||||
if tc.valid {
|
|
||||||
require.NoError(t, err)
|
|
||||||
} else {
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestJWTTest(t *testing.T) {
|
func TestJWTTest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
jwtService := &jwt.FakeJWTService{}
|
jwtService := &jwt.FakeJWTService{}
|
||||||
jwtHeaderName := "X-Forwarded-User"
|
jwtHeaderName := "X-Forwarded-User"
|
||||||
// #nosec G101 -- This is dummy/test token
|
// #nosec G101 -- This is dummy/test token
|
||||||
@ -280,14 +365,18 @@ func TestJWTTest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
t.Run(tc.desc, func(t *testing.T) {
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
cfg := &setting.Cfg{
|
cfg := &setting.Cfg{
|
||||||
JWTAuthEnabled: true,
|
JWTAuth: setting.AuthJWTSettings{
|
||||||
JWTAuthURLLogin: tc.urlLogin,
|
Enabled: true,
|
||||||
JWTAuthHeaderName: tc.cfgHeaderName,
|
URLLogin: tc.urlLogin,
|
||||||
JWTAuthAutoSignUp: true,
|
HeaderName: tc.cfgHeaderName,
|
||||||
JWTAuthAllowAssignGrafanaAdmin: true,
|
AutoSignUp: true,
|
||||||
JWTAuthRoleAttributeStrict: true,
|
AllowAssignGrafanaAdmin: true,
|
||||||
|
RoleAttributeStrict: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
jwtClient := ProvideJWT(jwtService, cfg)
|
jwtClient := ProvideJWT(jwtService, cfg)
|
||||||
httpReq := &http.Request{
|
httpReq := &http.Request{
|
||||||
@ -308,6 +397,7 @@ func TestJWTTest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestJWTStripParam(t *testing.T) {
|
func TestJWTStripParam(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
jwtService := &jwt.FakeJWTService{
|
jwtService := &jwt.FakeJWTService{
|
||||||
VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) {
|
VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) {
|
||||||
return jwt.JWTClaims{
|
return jwt.JWTClaims{
|
||||||
@ -323,15 +413,17 @@ func TestJWTStripParam(t *testing.T) {
|
|||||||
jwtHeaderName := "X-Forwarded-User"
|
jwtHeaderName := "X-Forwarded-User"
|
||||||
|
|
||||||
cfg := &setting.Cfg{
|
cfg := &setting.Cfg{
|
||||||
JWTAuthEnabled: true,
|
JWTAuth: setting.AuthJWTSettings{
|
||||||
JWTAuthHeaderName: jwtHeaderName,
|
Enabled: true,
|
||||||
JWTAuthAutoSignUp: true,
|
HeaderName: jwtHeaderName,
|
||||||
JWTAuthAllowAssignGrafanaAdmin: true,
|
AutoSignUp: true,
|
||||||
JWTAuthURLLogin: true,
|
AllowAssignGrafanaAdmin: true,
|
||||||
JWTAuthRoleAttributeStrict: false,
|
URLLogin: true,
|
||||||
JWTAuthRoleAttributePath: "roles",
|
RoleAttributeStrict: false,
|
||||||
JWTAuthEmailClaim: "email",
|
RoleAttributePath: "roles",
|
||||||
JWTAuthUsernameClaim: "preferred_username",
|
EmailClaim: "email",
|
||||||
|
UsernameClaim: "preferred_username",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// #nosec G101 -- This is a dummy/test token
|
// #nosec G101 -- This is a dummy/test token
|
||||||
|
@ -213,8 +213,8 @@ func WithAuthHTTPHeaders(ctx context.Context, cfg *setting.Cfg) context.Context
|
|||||||
list.Items = append(list.Items, "X-Grafana-Device-Id")
|
list.Items = append(list.Items, "X-Grafana-Device-Id")
|
||||||
|
|
||||||
// if jwt is enabled we add it to the list. We can ignore in case it is set to Authorization
|
// if jwt is enabled we add it to the list. We can ignore in case it is set to Authorization
|
||||||
if cfg.JWTAuthEnabled && cfg.JWTAuthHeaderName != "" && cfg.JWTAuthHeaderName != "Authorization" {
|
if cfg.JWTAuth.Enabled && cfg.JWTAuth.HeaderName != "" && cfg.JWTAuth.HeaderName != "Authorization" {
|
||||||
list.Items = append(list.Items, cfg.JWTAuthHeaderName)
|
list.Items = append(list.Items, cfg.JWTAuth.HeaderName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if auth proxy is enabled add the main proxy header and all configured headers
|
// if auth proxy is enabled add the main proxy header and all configured headers
|
||||||
|
@ -153,8 +153,8 @@ func TestContextHandler(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("should store auth header in context", func(t *testing.T) {
|
t.Run("should store auth header in context", func(t *testing.T) {
|
||||||
cfg := setting.NewCfg()
|
cfg := setting.NewCfg()
|
||||||
cfg.JWTAuthEnabled = true
|
cfg.JWTAuth.Enabled = true
|
||||||
cfg.JWTAuthHeaderName = "jwt-header"
|
cfg.JWTAuth.HeaderName = "jwt-header"
|
||||||
cfg.AuthProxyEnabled = true
|
cfg.AuthProxyEnabled = true
|
||||||
cfg.AuthProxyHeaderName = "proxy-header"
|
cfg.AuthProxyHeaderName = "proxy-header"
|
||||||
cfg.AuthProxyHeaders = map[string]string{
|
cfg.AuthProxyHeaders = map[string]string{
|
||||||
|
@ -76,7 +76,7 @@ func IsExternallySynced(cfg *setting.Cfg, authModule string, oauthInfo *social.O
|
|||||||
case LDAPAuthModule:
|
case LDAPAuthModule:
|
||||||
return !cfg.LDAPSkipOrgRoleSync
|
return !cfg.LDAPSkipOrgRoleSync
|
||||||
case JWTModule:
|
case JWTModule:
|
||||||
return !cfg.JWTAuthSkipOrgRoleSync
|
return !cfg.JWTAuth.SkipOrgRoleSync
|
||||||
}
|
}
|
||||||
// then check the rest of the oauth providers
|
// then check the rest of the oauth providers
|
||||||
// FIXME: remove this once we remove the setting
|
// FIXME: remove this once we remove the setting
|
||||||
@ -104,7 +104,7 @@ func IsGrafanaAdminExternallySynced(cfg *setting.Cfg, oauthInfo *social.OAuthInf
|
|||||||
|
|
||||||
switch authModule {
|
switch authModule {
|
||||||
case JWTModule:
|
case JWTModule:
|
||||||
return cfg.JWTAuthAllowAssignGrafanaAdmin
|
return cfg.JWTAuth.AllowAssignGrafanaAdmin
|
||||||
case SAMLAuthModule:
|
case SAMLAuthModule:
|
||||||
return cfg.SAMLRoleValuesGrafanaAdmin != ""
|
return cfg.SAMLRoleValuesGrafanaAdmin != ""
|
||||||
case LDAPAuthModule:
|
case LDAPAuthModule:
|
||||||
@ -121,7 +121,7 @@ func IsProviderEnabled(cfg *setting.Cfg, authModule string, oauthInfo *social.OA
|
|||||||
case LDAPAuthModule:
|
case LDAPAuthModule:
|
||||||
return cfg.LDAPAuthEnabled
|
return cfg.LDAPAuthEnabled
|
||||||
case JWTModule:
|
case JWTModule:
|
||||||
return cfg.JWTAuthEnabled
|
return cfg.JWTAuth.Enabled
|
||||||
case GoogleAuthModule, OktaAuthModule, AzureADAuthModule, GitLabAuthModule, GithubAuthModule, GrafanaComAuthModule, GenericOAuthModule:
|
case GoogleAuthModule, OktaAuthModule, AzureADAuthModule, GitLabAuthModule, GithubAuthModule, GrafanaComAuthModule, GenericOAuthModule:
|
||||||
if oauthInfo == nil {
|
if oauthInfo == nil {
|
||||||
return false
|
return false
|
||||||
|
@ -3,9 +3,10 @@ package login
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/login/social"
|
"github.com/grafana/grafana/pkg/login/social"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIsExternallySynced(t *testing.T) {
|
func TestIsExternallySynced(t *testing.T) {
|
||||||
@ -82,20 +83,20 @@ func TestIsExternallySynced(t *testing.T) {
|
|||||||
// jwt
|
// jwt
|
||||||
{
|
{
|
||||||
name: "JWT synced user should return that it is externally synced",
|
name: "JWT synced user should return that it is externally synced",
|
||||||
cfg: &setting.Cfg{JWTAuthEnabled: true, JWTAuthSkipOrgRoleSync: false},
|
cfg: &setting.Cfg{JWTAuth: setting.AuthJWTSettings{Enabled: true, SkipOrgRoleSync: false}},
|
||||||
provider: JWTModule,
|
provider: JWTModule,
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "JWT synced user should return that it is not externally synced when org role sync is set",
|
name: "JWT synced user should return that it is not externally synced when org role sync is set",
|
||||||
cfg: &setting.Cfg{JWTAuthEnabled: true, JWTAuthSkipOrgRoleSync: true},
|
cfg: &setting.Cfg{JWTAuth: setting.AuthJWTSettings{Enabled: true, SkipOrgRoleSync: true}},
|
||||||
provider: JWTModule,
|
provider: JWTModule,
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
// IsProvider test
|
// IsProvider test
|
||||||
{
|
{
|
||||||
name: "If no provider enabled should return false",
|
name: "If no provider enabled should return false",
|
||||||
cfg: &setting.Cfg{JWTAuthSkipOrgRoleSync: true},
|
cfg: &setting.Cfg{JWTAuth: setting.AuthJWTSettings{Enabled: false, SkipOrgRoleSync: true}},
|
||||||
provider: JWTModule,
|
provider: JWTModule,
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
|
@ -267,24 +267,7 @@ type Cfg struct {
|
|||||||
OAuthCookieMaxAge int
|
OAuthCookieMaxAge int
|
||||||
OAuthAllowInsecureEmailLookup bool
|
OAuthAllowInsecureEmailLookup bool
|
||||||
|
|
||||||
// JWT Auth
|
JWTAuth AuthJWTSettings
|
||||||
JWTAuthEnabled bool
|
|
||||||
JWTAuthHeaderName string
|
|
||||||
JWTAuthURLLogin bool
|
|
||||||
JWTAuthEmailClaim string
|
|
||||||
JWTAuthUsernameClaim string
|
|
||||||
JWTAuthExpectClaims string
|
|
||||||
JWTAuthJWKSetURL string
|
|
||||||
JWTAuthCacheTTL time.Duration
|
|
||||||
JWTAuthKeyFile string
|
|
||||||
JWTAuthKeyID string
|
|
||||||
JWTAuthJWKSetFile string
|
|
||||||
JWTAuthAutoSignUp bool
|
|
||||||
JWTAuthRoleAttributePath string
|
|
||||||
JWTAuthRoleAttributeStrict bool
|
|
||||||
JWTAuthAllowAssignGrafanaAdmin bool
|
|
||||||
JWTAuthSkipOrgRoleSync bool
|
|
||||||
|
|
||||||
// Extended JWT Auth
|
// Extended JWT Auth
|
||||||
ExtendedJWTAuthEnabled bool
|
ExtendedJWTAuthEnabled bool
|
||||||
ExtendedJWTExpectIssuer string
|
ExtendedJWTExpectIssuer string
|
||||||
@ -1195,6 +1178,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
|
|||||||
cfg.readLDAPConfig()
|
cfg.readLDAPConfig()
|
||||||
cfg.handleAWSConfig()
|
cfg.handleAWSConfig()
|
||||||
cfg.readAzureSettings()
|
cfg.readAzureSettings()
|
||||||
|
cfg.readAuthJWTSettings()
|
||||||
cfg.readSessionConfig()
|
cfg.readSessionConfig()
|
||||||
if err := cfg.readSmtpSettings(); err != nil {
|
if err := cfg.readSmtpSettings(); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -1608,25 +1592,6 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) {
|
|||||||
authBasic := iniFile.Section("auth.basic")
|
authBasic := iniFile.Section("auth.basic")
|
||||||
cfg.BasicAuthEnabled = authBasic.Key("enabled").MustBool(true)
|
cfg.BasicAuthEnabled = authBasic.Key("enabled").MustBool(true)
|
||||||
|
|
||||||
// JWT auth
|
|
||||||
authJWT := iniFile.Section("auth.jwt")
|
|
||||||
cfg.JWTAuthEnabled = authJWT.Key("enabled").MustBool(false)
|
|
||||||
cfg.JWTAuthHeaderName = valueAsString(authJWT, "header_name", "")
|
|
||||||
cfg.JWTAuthURLLogin = authJWT.Key("url_login").MustBool(false)
|
|
||||||
cfg.JWTAuthEmailClaim = valueAsString(authJWT, "email_claim", "")
|
|
||||||
cfg.JWTAuthUsernameClaim = valueAsString(authJWT, "username_claim", "")
|
|
||||||
cfg.JWTAuthExpectClaims = valueAsString(authJWT, "expect_claims", "{}")
|
|
||||||
cfg.JWTAuthJWKSetURL = valueAsString(authJWT, "jwk_set_url", "")
|
|
||||||
cfg.JWTAuthCacheTTL = authJWT.Key("cache_ttl").MustDuration(time.Minute * 60)
|
|
||||||
cfg.JWTAuthKeyFile = valueAsString(authJWT, "key_file", "")
|
|
||||||
cfg.JWTAuthKeyID = authJWT.Key("key_id").MustString("")
|
|
||||||
cfg.JWTAuthJWKSetFile = valueAsString(authJWT, "jwk_set_file", "")
|
|
||||||
cfg.JWTAuthAutoSignUp = authJWT.Key("auto_sign_up").MustBool(false)
|
|
||||||
cfg.JWTAuthRoleAttributePath = valueAsString(authJWT, "role_attribute_path", "")
|
|
||||||
cfg.JWTAuthRoleAttributeStrict = authJWT.Key("role_attribute_strict").MustBool(false)
|
|
||||||
cfg.JWTAuthAllowAssignGrafanaAdmin = authJWT.Key("allow_assign_grafana_admin").MustBool(false)
|
|
||||||
cfg.JWTAuthSkipOrgRoleSync = authJWT.Key("skip_org_role_sync").MustBool(false)
|
|
||||||
|
|
||||||
// Extended JWT auth
|
// Extended JWT auth
|
||||||
authExtendedJWT := cfg.SectionWithEnvOverrides("auth.extended_jwt")
|
authExtendedJWT := cfg.SectionWithEnvOverrides("auth.extended_jwt")
|
||||||
cfg.ExtendedJWTAuthEnabled = authExtendedJWT.Key("enabled").MustBool(false)
|
cfg.ExtendedJWTAuthEnabled = authExtendedJWT.Key("enabled").MustBool(false)
|
||||||
|
48
pkg/setting/setting_jwt.go
Normal file
48
pkg/setting/setting_jwt.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package setting
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type AuthJWTSettings struct {
|
||||||
|
// JWT Auth
|
||||||
|
Enabled bool
|
||||||
|
HeaderName string
|
||||||
|
URLLogin bool
|
||||||
|
EmailClaim string
|
||||||
|
UsernameClaim string
|
||||||
|
ExpectClaims string
|
||||||
|
JWKSetURL string
|
||||||
|
CacheTTL time.Duration
|
||||||
|
KeyFile string
|
||||||
|
KeyID string
|
||||||
|
JWKSetFile string
|
||||||
|
AutoSignUp bool
|
||||||
|
RoleAttributePath string
|
||||||
|
RoleAttributeStrict bool
|
||||||
|
AllowAssignGrafanaAdmin bool
|
||||||
|
SkipOrgRoleSync bool
|
||||||
|
GroupsAttributePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Cfg) readAuthJWTSettings() {
|
||||||
|
jwtSettings := AuthJWTSettings{}
|
||||||
|
authJWT := cfg.Raw.Section("auth.jwt")
|
||||||
|
jwtSettings.Enabled = authJWT.Key("enabled").MustBool(false)
|
||||||
|
jwtSettings.HeaderName = valueAsString(authJWT, "header_name", "")
|
||||||
|
jwtSettings.URLLogin = authJWT.Key("url_login").MustBool(false)
|
||||||
|
jwtSettings.EmailClaim = valueAsString(authJWT, "email_claim", "")
|
||||||
|
jwtSettings.UsernameClaim = valueAsString(authJWT, "username_claim", "")
|
||||||
|
jwtSettings.ExpectClaims = valueAsString(authJWT, "expect_claims", "{}")
|
||||||
|
jwtSettings.JWKSetURL = valueAsString(authJWT, "jwk_set_url", "")
|
||||||
|
jwtSettings.CacheTTL = authJWT.Key("cache_ttl").MustDuration(time.Minute * 60)
|
||||||
|
jwtSettings.KeyFile = valueAsString(authJWT, "key_file", "")
|
||||||
|
jwtSettings.KeyID = authJWT.Key("key_id").MustString("")
|
||||||
|
jwtSettings.JWKSetFile = valueAsString(authJWT, "jwk_set_file", "")
|
||||||
|
jwtSettings.AutoSignUp = authJWT.Key("auto_sign_up").MustBool(false)
|
||||||
|
jwtSettings.RoleAttributePath = valueAsString(authJWT, "role_attribute_path", "")
|
||||||
|
jwtSettings.RoleAttributeStrict = authJWT.Key("role_attribute_strict").MustBool(false)
|
||||||
|
jwtSettings.AllowAssignGrafanaAdmin = authJWT.Key("allow_assign_grafana_admin").MustBool(false)
|
||||||
|
jwtSettings.SkipOrgRoleSync = authJWT.Key("skip_org_role_sync").MustBool(false)
|
||||||
|
jwtSettings.GroupsAttributePath = valueAsString(authJWT, "groups_attribute_path", "")
|
||||||
|
|
||||||
|
cfg.JWTAuth = jwtSettings
|
||||||
|
}
|
108
pkg/util/json.go
108
pkg/util/json.go
@ -1,4 +1,112 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/jmespath/go-jmespath"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/util/errutil"
|
||||||
|
)
|
||||||
|
|
||||||
// DynMap defines a dynamic map interface.
|
// DynMap defines a dynamic map interface.
|
||||||
type DynMap map[string]any
|
type DynMap map[string]any
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrEmptyJSON is an error for empty attribute in JSON.
|
||||||
|
ErrEmptyJSON = errutil.NewBase(errutil.StatusBadRequest,
|
||||||
|
"json-missing-body", errutil.WithPublicMessage("Empty JSON provided"))
|
||||||
|
|
||||||
|
// ErrNoAttributePathSpecified is an error for no attribute path specified.
|
||||||
|
ErrNoAttributePathSpecified = errutil.NewBase(errutil.StatusBadRequest,
|
||||||
|
"json-no-attribute-path-specified", errutil.WithPublicMessage("No attribute path specified"))
|
||||||
|
|
||||||
|
// ErrFailedToUnmarshalJSON is an error for failure in unmarshalling JSON.
|
||||||
|
ErrFailedToUnmarshalJSON = errutil.NewBase(errutil.StatusBadRequest,
|
||||||
|
"json-failed-to-unmarshal", errutil.WithPublicMessage("Failed to unmarshal JSON"))
|
||||||
|
|
||||||
|
// ErrFailedToSearchJSON is an error for failure in searching JSON.
|
||||||
|
ErrFailedToSearchJSON = errutil.NewBase(errutil.StatusBadRequest,
|
||||||
|
"json-failed-to-search", errutil.WithPublicMessage("Failed to search JSON with provided path"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchJSONForStringSliceAttr searches for a slice attribute in a JSON object and returns a string slice.
|
||||||
|
// The attributePath parameter is a string that specifies the path to the attribute.
|
||||||
|
// The data parameter is the JSON object that we're searching. It can be a byte slice or a go type.
|
||||||
|
func SearchJSONForStringSliceAttr(attributePath string, data any) ([]string, error) {
|
||||||
|
val, err := searchJSONForAttr(attributePath, data)
|
||||||
|
if err != nil {
|
||||||
|
return []string{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ifArr, ok := val.([]any)
|
||||||
|
if !ok {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := []string{}
|
||||||
|
for _, v := range ifArr {
|
||||||
|
if strVal, ok := v.(string); ok {
|
||||||
|
result = append(result, strVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchJSONForStringAttr searches for a specific attribute in a JSON object and returns a string.
|
||||||
|
// The attributePath parameter is a string that specifies the path to the attribute.
|
||||||
|
// The data parameter is the JSON object that we're searching. It can be a byte slice or a go type.
|
||||||
|
func SearchJSONForStringAttr(attributePath string, data any) (string, error) {
|
||||||
|
val, err := searchJSONForAttr(attributePath, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
strVal, ok := val.(string)
|
||||||
|
if ok {
|
||||||
|
return strVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchJSONForAttr searches for a specific attribute in a JSON object.
|
||||||
|
// The attributePath parameter is a string that specifies the path to the attribute.
|
||||||
|
// The data parameter is the JSON object that we're searching.
|
||||||
|
// The function returns the value of the attribute and an error if one occurred.
|
||||||
|
func searchJSONForAttr(attributePath string, data any) (any, error) {
|
||||||
|
// If no attribute path is specified, return an error
|
||||||
|
if attributePath == "" {
|
||||||
|
return "", ErrNoAttributePathSpecified.Errorf("attribute path: %q", attributePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the data is nil, return an error
|
||||||
|
if data == nil {
|
||||||
|
return "", ErrEmptyJSON.Errorf("empty json, attribute path: %q", attributePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the data to a new variable
|
||||||
|
var jsonData = data
|
||||||
|
|
||||||
|
// If the data is a byte slice, try to unmarshal it into a JSON object
|
||||||
|
if dataBytes, ok := data.([]byte); ok {
|
||||||
|
// If the byte slice is empty, return an error
|
||||||
|
if len(dataBytes) == 0 {
|
||||||
|
return "", ErrEmptyJSON.Errorf("empty json, attribute path: %q", attributePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to unmarshal the byte slice
|
||||||
|
if err := json.Unmarshal(dataBytes, &jsonData); err != nil {
|
||||||
|
return "", ErrFailedToUnmarshalJSON.Errorf("%v: %w", "failed to unmarshal user info JSON response", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for the attribute in the JSON object
|
||||||
|
value, err := jmespath.Search(attributePath, jsonData)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrFailedToSearchJSON.Errorf("failed to search user info JSON response with provided path: %q: %w", attributePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the value and nil error
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
155
pkg/util/json_test.go
Normal file
155
pkg/util/json_test.go
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
package util_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearchJSONForGroups(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
Name string
|
||||||
|
searchObject any
|
||||||
|
GroupsAttributePath string
|
||||||
|
ExpectedResult []string
|
||||||
|
ExpectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "Given an invalid user info JSON response",
|
||||||
|
searchObject: []byte("{"),
|
||||||
|
GroupsAttributePath: "attributes.groups",
|
||||||
|
ExpectedResult: []string{},
|
||||||
|
ExpectedError: util.ErrFailedToUnmarshalJSON,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Given an empty user info JSON response and empty JMES path",
|
||||||
|
searchObject: []byte{},
|
||||||
|
GroupsAttributePath: "",
|
||||||
|
ExpectedResult: []string{},
|
||||||
|
ExpectedError: util.ErrNoAttributePathSpecified,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Given an empty user info JSON response and valid JMES path",
|
||||||
|
searchObject: []byte{},
|
||||||
|
GroupsAttributePath: "attributes.groups",
|
||||||
|
ExpectedResult: []string{},
|
||||||
|
ExpectedError: util.ErrEmptyJSON,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Given a nil JSON and valid JMES path",
|
||||||
|
searchObject: []byte{},
|
||||||
|
GroupsAttributePath: "attributes.groups",
|
||||||
|
ExpectedResult: []string{},
|
||||||
|
ExpectedError: util.ErrEmptyJSON,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Given a simple user info JSON response and valid JMES path",
|
||||||
|
searchObject: []byte(`{
|
||||||
|
"attributes": {
|
||||||
|
"groups": ["foo", "bar"]
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
GroupsAttributePath: "attributes.groups[]",
|
||||||
|
ExpectedResult: []string{"foo", "bar"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Given a simple object and valid JMES path",
|
||||||
|
searchObject: map[string]any{
|
||||||
|
"attributes": map[string]any{
|
||||||
|
"groups": []string{"foo", "bar"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
GroupsAttributePath: "attributes.groups[]",
|
||||||
|
ExpectedResult: []string{"foo", "bar"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
test := test
|
||||||
|
t.Run(test.Name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
actualResult, err := util.SearchJSONForStringSliceAttr(
|
||||||
|
test.GroupsAttributePath, test.searchObject)
|
||||||
|
if test.ExpectedError == nil {
|
||||||
|
require.NoError(t, err)
|
||||||
|
} else {
|
||||||
|
require.ErrorIs(t, err, test.ExpectedError)
|
||||||
|
}
|
||||||
|
require.Equal(t, test.ExpectedResult, actualResult)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchJSONForEmail(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
Name string
|
||||||
|
UserInfoJSONResponse any
|
||||||
|
EmailAttributePath string
|
||||||
|
ExpectedResult string
|
||||||
|
ExpectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "Given a simple user info JSON response and valid JMES path",
|
||||||
|
UserInfoJSONResponse: []byte(`{
|
||||||
|
"attributes": {
|
||||||
|
"email": "grafana@localhost"
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
EmailAttributePath: "attributes.email",
|
||||||
|
ExpectedResult: "grafana@localhost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Given a simple object and valid JMES path",
|
||||||
|
UserInfoJSONResponse: map[string]any{
|
||||||
|
"attributes": map[string]any{
|
||||||
|
"email": "grafana@localhost",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
EmailAttributePath: "attributes.email",
|
||||||
|
ExpectedResult: "grafana@localhost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Given a user info JSON response with e-mails array and valid JMES path",
|
||||||
|
UserInfoJSONResponse: []byte(`{
|
||||||
|
"attributes": {
|
||||||
|
"emails": ["grafana@localhost", "admin@localhost"]
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
EmailAttributePath: "attributes.emails[0]",
|
||||||
|
ExpectedResult: "grafana@localhost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Given a nested user info JSON response and valid JMES path",
|
||||||
|
UserInfoJSONResponse: []byte(`{
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"userId": "grafana@localhost"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "admin@localhost"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`),
|
||||||
|
EmailAttributePath: "identities[0].userId",
|
||||||
|
ExpectedResult: "grafana@localhost",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
test := test
|
||||||
|
t.Run(test.Name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
actualResult, err := util.SearchJSONForStringAttr(test.EmailAttributePath, test.UserInfoJSONResponse)
|
||||||
|
if test.ExpectedError != nil {
|
||||||
|
require.NoError(t, err)
|
||||||
|
} else {
|
||||||
|
require.ErrorIs(t, err, test.ExpectedError)
|
||||||
|
}
|
||||||
|
require.Equal(t, test.ExpectedResult, actualResult)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user