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 =
|
||||
role_attribute_path =
|
||||
role_attribute_strict = false
|
||||
groups_attribute_path =
|
||||
auto_sign_up = false
|
||||
url_login = 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
|
||||
;key_id = some-key-id
|
||||
;role_attribute_path =
|
||||
;groups_attribute_path =
|
||||
;role_attribute_strict = false
|
||||
;auto_sign_up = false
|
||||
;url_login = false
|
||||
@ -1639,4 +1640,3 @@
|
||||
[public_dashboards]
|
||||
# Set to false to disable public dashboards
|
||||
;enabled = true
|
||||
|
||||
|
@ -24,6 +24,7 @@ expect_claims = {"iss": "http://env.grafana.local:8087/realms/grafana", "azp": "
|
||||
auto_sign_up = true
|
||||
role_attribute_path = contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin' || contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer'
|
||||
role_attribute_strict = false
|
||||
groups_attribute_path = groups[]
|
||||
allow_assign_grafana_admin = true
|
||||
```
|
||||
|
||||
|
@ -320,9 +320,9 @@ func Test_AdminUpdateUserPermissions(t *testing.T) {
|
||||
case login.GenericOAuthModule:
|
||||
socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{AllowAssignGrafanaAdmin: tc.allowAssignGrafanaAdmin, Enabled: tc.authEnabled, SkipOrgRoleSync: tc.skipOrgRoleSync}
|
||||
case login.JWTModule:
|
||||
cfg.JWTAuthEnabled = tc.authEnabled
|
||||
cfg.JWTAuthSkipOrgRoleSync = tc.skipOrgRoleSync
|
||||
cfg.JWTAuthAllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin
|
||||
cfg.JWTAuth.Enabled = tc.authEnabled
|
||||
cfg.JWTAuth.SkipOrgRoleSync = tc.skipOrgRoleSync
|
||||
cfg.JWTAuth.AllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin
|
||||
}
|
||||
|
||||
hs := &HTTPServer{
|
||||
|
@ -173,8 +173,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
AllowOrgCreate: (hs.Cfg.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin,
|
||||
AuthProxyEnabled: hs.Cfg.AuthProxyEnabled,
|
||||
LdapEnabled: hs.Cfg.LDAPAuthEnabled,
|
||||
JwtHeaderName: hs.Cfg.JWTAuthHeaderName,
|
||||
JwtUrlLogin: hs.Cfg.JWTAuthURLLogin,
|
||||
JwtHeaderName: hs.Cfg.JWTAuth.HeaderName,
|
||||
JwtUrlLogin: hs.Cfg.JWTAuth.URLLogin,
|
||||
AlertingErrorOrTimeout: hs.Cfg.AlertingErrorOrTimeout,
|
||||
AlertingNoDataOrNullValues: hs.Cfg.AlertingNoDataOrNullValues,
|
||||
AlertingMinInterval: hs.Cfg.AlertingMinInterval,
|
||||
@ -321,7 +321,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
OAuthSkipOrgRoleUpdateSync: hs.Cfg.OAuthSkipOrgRoleUpdateSync,
|
||||
SAMLSkipOrgRoleSync: hs.Cfg.SAMLSkipOrgRoleSync,
|
||||
LDAPSkipOrgRoleSync: hs.Cfg.LDAPSkipOrgRoleSync,
|
||||
JWTAuthSkipOrgRoleSync: hs.Cfg.JWTAuthSkipOrgRoleSync,
|
||||
JWTAuthSkipOrgRoleSync: hs.Cfg.JWTAuth.SkipOrgRoleSync,
|
||||
GoogleSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GoogleProviderName]),
|
||||
GrafanaComSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GrafanaComProviderName]),
|
||||
GenericOAuthSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GenericOAuthProviderName]),
|
||||
|
@ -302,9 +302,9 @@ func Test_GetUserByID(t *testing.T) {
|
||||
case login.GenericOAuthModule:
|
||||
socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{AllowAssignGrafanaAdmin: tc.allowAssignGrafanaAdmin, Enabled: tc.authEnabled, SkipOrgRoleSync: tc.skipOrgRoleSync}
|
||||
case login.JWTModule:
|
||||
cfg.JWTAuthEnabled = tc.authEnabled
|
||||
cfg.JWTAuthSkipOrgRoleSync = tc.skipOrgRoleSync
|
||||
cfg.JWTAuthAllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin
|
||||
cfg.JWTAuth.Enabled = tc.authEnabled
|
||||
cfg.JWTAuth.SkipOrgRoleSync = tc.skipOrgRoleSync
|
||||
cfg.JWTAuth.AllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin
|
||||
}
|
||||
|
||||
hs := &HTTPServer{
|
||||
|
@ -2,8 +2,6 @@ package connectors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@ -12,7 +10,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jmespath/go-jmespath"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
@ -96,63 +93,6 @@ func (s *SocialBase) httpGet(ctx context.Context, client *http.Client, url strin
|
||||
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 {
|
||||
var authStyle oauth2.AuthStyle
|
||||
switch strings.ToLower(info.AuthStyle) {
|
||||
|
@ -363,7 +363,7 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
|
||||
}
|
||||
|
||||
if s.emailAttributePath != "" {
|
||||
email, err := s.searchJSONForStringAttr(s.emailAttributePath, data.rawJSON)
|
||||
email, err := util.SearchJSONForStringAttr(s.emailAttributePath, data.rawJSON)
|
||||
if err != nil {
|
||||
s.log.Error("Failed to search JSON for attribute", "error", err)
|
||||
} else if email != "" {
|
||||
@ -395,7 +395,7 @@ func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson) string {
|
||||
|
||||
if 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 {
|
||||
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 {
|
||||
if s.nameAttributePath != "" {
|
||||
name, err := s.searchJSONForStringAttr(s.nameAttributePath, data.rawJSON)
|
||||
name, err := util.SearchJSONForStringAttr(s.nameAttributePath, data.rawJSON)
|
||||
if err != nil {
|
||||
s.log.Error("Failed to search JSON for attribute", "error", err)
|
||||
} else if name != "" {
|
||||
@ -443,7 +443,7 @@ func (s *SocialGenericOAuth) extractGroups(data *UserInfoJson) ([]string, error)
|
||||
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) {
|
||||
@ -554,7 +554,7 @@ func (s *SocialGenericOAuth) fetchTeamMembershipsFromTeamsUrl(ctx context.Contex
|
||||
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) {
|
||||
|
@ -23,208 +23,6 @@ import (
|
||||
"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) {
|
||||
provider := NewGenericOAuthProvider(&social.OAuthInfo{
|
||||
EmailAttributePath: "email",
|
||||
|
@ -22,6 +22,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/ssosettings"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
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) {
|
||||
role, err := s.searchJSONForStringAttr(s.info.RoleAttributePath, rawJSON)
|
||||
role, err := util.SearchJSONForStringAttr(s.info.RoleAttributePath, rawJSON)
|
||||
if err == nil && role != "" {
|
||||
return getRoleFromSearch(role)
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
return getRoleFromSearch(role)
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ func newService(cfg *setting.Cfg, remoteCache *remotecache.RemoteCache) *AuthSer
|
||||
}
|
||||
|
||||
func (s *AuthService) init() error {
|
||||
if !s.Cfg.JWTAuthEnabled {
|
||||
if !s.Cfg.JWTAuth.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -75,7 +75,7 @@ func TestVerifyUsingPKIXPublicKeyFile(t *testing.T) {
|
||||
assert.Equal(t, verifiedClaims["sub"], subject)
|
||||
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
|
||||
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, 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) {
|
||||
@ -123,18 +123,18 @@ func TestVerifyUsingJWKSetURL(t *testing.T) {
|
||||
var err error
|
||||
|
||||
_, 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)
|
||||
|
||||
_, 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)
|
||||
|
||||
_, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) {
|
||||
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)
|
||||
})
|
||||
@ -185,7 +185,7 @@ func TestCachingJWKHTTPResponse(t *testing.T) {
|
||||
assert.Equal(t, 1, *sc.reqCount)
|
||||
}, func(t *testing.T, cfg *setting.Cfg) {
|
||||
// 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) {
|
||||
@ -196,7 +196,7 @@ func TestCachingJWKHTTPResponse(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 2, *sc.reqCount)
|
||||
}, 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)
|
||||
require.Error(t, err)
|
||||
}, 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) {
|
||||
@ -236,7 +236,7 @@ func TestClaimValidation(t *testing.T) {
|
||||
_, err = sc.authJWTSvc.Verify(sc.ctx, tokenInvalid)
|
||||
require.Error(t, err)
|
||||
}, 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) {
|
||||
@ -257,7 +257,7 @@ func TestClaimValidation(t *testing.T) {
|
||||
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"baz"}}, nil))
|
||||
require.Error(t, err)
|
||||
}, 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) {
|
||||
@ -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))
|
||||
require.Error(t, err)
|
||||
}, 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) {
|
||||
@ -323,7 +323,7 @@ func jwkHTTPScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...configur
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
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) {
|
||||
keySet := sc.authJWTSvc.keySet.(*keySetHTTP)
|
||||
@ -355,8 +355,8 @@ func jwkCachingScenario(t *testing.T, desc string, fn cachingScenarioFunc, cbs .
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
configure := func(t *testing.T, cfg *setting.Cfg) {
|
||||
cfg.JWTAuthJWKSetURL = ts.URL
|
||||
cfg.JWTAuthCacheTTL = time.Hour
|
||||
cfg.JWTAuth.JWKSetURL = ts.URL
|
||||
cfg.JWTAuth.CacheTTL = time.Hour
|
||||
}
|
||||
runner := scenarioRunner(func(t *testing.T, sc scenarioContext) {
|
||||
keySet := sc.authJWTSvc.keySet.(*keySetHTTP)
|
||||
@ -397,8 +397,8 @@ func initAuthService(t *testing.T, cbs ...configureFunc) (*AuthService, error) {
|
||||
t.Helper()
|
||||
|
||||
cfg := setting.NewCfg()
|
||||
cfg.JWTAuthEnabled = true
|
||||
cfg.JWTAuthExpectClaims = "{}"
|
||||
cfg.JWTAuth.Enabled = true
|
||||
cfg.JWTAuth.ExpectClaims = "{}"
|
||||
|
||||
for _, cb := range cbs {
|
||||
cb(t, cfg)
|
||||
@ -442,5 +442,5 @@ func configurePKIXPublicKeyFile(t *testing.T, cfg *setting.Cfg) {
|
||||
}))
|
||||
require.NoError(t, file.Close())
|
||||
|
||||
cfg.JWTAuthKeyFile = file.Name()
|
||||
cfg.JWTAuth.KeyFile = file.Name()
|
||||
}
|
||||
|
@ -2,9 +2,11 @@ package jwt
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
type JWTClaims map[string]any
|
||||
type JWTClaims util.DynMap
|
||||
|
||||
type JWTService interface {
|
||||
Verify(ctx context.Context, strToken string) (JWTClaims, error)
|
||||
|
@ -49,13 +49,13 @@ type keySetHTTP struct {
|
||||
|
||||
func (s *AuthService) checkKeySetConfiguration() error {
|
||||
var count int
|
||||
if s.Cfg.JWTAuthKeyFile != "" {
|
||||
if s.Cfg.JWTAuth.KeyFile != "" {
|
||||
count++
|
||||
}
|
||||
if s.Cfg.JWTAuthJWKSetFile != "" {
|
||||
if s.Cfg.JWTAuth.JWKSetFile != "" {
|
||||
count++
|
||||
}
|
||||
if s.Cfg.JWTAuthJWKSetURL != "" {
|
||||
if s.Cfg.JWTAuth.JWKSetURL != "" {
|
||||
count++
|
||||
}
|
||||
|
||||
@ -75,7 +75,7 @@ func (s *AuthService) initKeySet() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if keyFilePath := s.Cfg.JWTAuthKeyFile; keyFilePath != "" {
|
||||
if keyFilePath := s.Cfg.JWTAuth.KeyFile; keyFilePath != "" {
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `fileName` comes from grafana configuration file
|
||||
file, err := os.Open(keyFilePath)
|
||||
@ -125,10 +125,10 @@ func (s *AuthService) initKeySet() error {
|
||||
|
||||
s.keySet = &keySetJWKS{
|
||||
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
|
||||
// We can ignore the gosec G304 warning on this one because `fileName` comes from grafana configuration file
|
||||
file, err := os.Open(keyFilePath)
|
||||
@ -147,7 +147,7 @@ func (s *AuthService) initKeySet() error {
|
||||
}
|
||||
|
||||
s.keySet = &keySetJWKS{jwks}
|
||||
} else if urlStr := s.Cfg.JWTAuthJWKSetURL; urlStr != "" {
|
||||
} else if urlStr := s.Cfg.JWTAuth.JWKSetURL; urlStr != "" {
|
||||
urlParsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -176,7 +176,7 @@ func (s *AuthService) initKeySet() error {
|
||||
Timeout: time.Second * 30,
|
||||
},
|
||||
cacheKey: fmt.Sprintf("auth-jwt:jwk-%s", urlStr),
|
||||
cacheExpiration: s.Cfg.JWTAuthCacheTTL,
|
||||
cacheExpiration: s.Cfg.JWTAuth.CacheTTL,
|
||||
cache: s.RemoteCache,
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -131,7 +131,7 @@ func ProvideService(
|
||||
}
|
||||
}
|
||||
|
||||
if s.cfg.JWTAuthEnabled {
|
||||
if s.cfg.JWTAuth.Enabled {
|
||||
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["auth_proxy"] = s.cfg.AuthProxyEnabled
|
||||
authTypes["anonymous"] = s.cfg.AnonymousEnabled
|
||||
authTypes["jwt"] = s.cfg.JWTAuthEnabled
|
||||
authTypes["jwt"] = s.cfg.JWTAuth.Enabled
|
||||
authTypes["grafana_password"] = !s.cfg.DisableLogin
|
||||
authTypes["login_form"] = !s.cfg.DisableLoginForm
|
||||
|
||||
|
@ -21,7 +21,7 @@ func TestService_getUsageStats(t *testing.T) {
|
||||
svc.cfg.DisableLogin = false
|
||||
svc.cfg.BasicAuthEnabled = true
|
||||
svc.cfg.AuthProxyEnabled = true
|
||||
svc.cfg.JWTAuthEnabled = true
|
||||
svc.cfg.JWTAuth.Enabled = true
|
||||
svc.cfg.LDAPAuthEnabled = true
|
||||
svc.cfg.EditorsCanAdmin = true
|
||||
svc.cfg.ViewersCanEdit = true
|
||||
|
@ -2,13 +2,9 @@ package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jmespath/go-jmespath"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
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/org"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"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,
|
||||
FetchSyncedUser: true,
|
||||
SyncPermissions: true,
|
||||
SyncOrgRoles: !s.cfg.JWTAuthSkipOrgRoleSync,
|
||||
AllowSignUp: s.cfg.JWTAuthAutoSignUp,
|
||||
SyncOrgRoles: !s.cfg.JWTAuth.SkipOrgRoleSync,
|
||||
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.ClientParams.LookUpParams.Login = &id.Login
|
||||
}
|
||||
if key := s.cfg.JWTAuthEmailClaim; key != "" {
|
||||
if key := s.cfg.JWTAuth.EmailClaim; key != "" {
|
||||
id.Email, _ = claims[key].(string)
|
||||
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) {
|
||||
if s.cfg.JWTAuthSkipOrgRoleSync {
|
||||
if s.cfg.JWTAuth.SkipOrgRoleSync {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if !s.cfg.JWTAuthAllowAssignGrafanaAdmin {
|
||||
if !s.cfg.JWTAuth.AllowAssignGrafanaAdmin {
|
||||
return role, nil, nil
|
||||
}
|
||||
|
||||
@ -114,6 +112,11 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi
|
||||
id.OrgRoles = orgRoles
|
||||
id.IsGrafanaAdmin = isGrafanaAdmin
|
||||
|
||||
id.Groups, err = s.extractGroups(claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if id.Login == "" && id.Email == "" {
|
||||
s.log.FromContext(ctx).Debug("Failed to get an authentication claim from JWT",
|
||||
"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
|
||||
// avoid JWT URL login passing auth_token in URL
|
||||
func (s *JWT) stripSensitiveParam(httpRequest *http.Request) {
|
||||
if s.cfg.JWTAuthURLLogin {
|
||||
if s.cfg.JWTAuth.URLLogin {
|
||||
params := httpRequest.URL.Query()
|
||||
if params.Has(authQueryParamName) {
|
||||
params.Del(authQueryParamName)
|
||||
@ -137,8 +140,8 @@ func (s *JWT) stripSensitiveParam(httpRequest *http.Request) {
|
||||
|
||||
// retrieveToken retrieves the JWT token from the request.
|
||||
func (s *JWT) retrieveToken(httpRequest *http.Request) string {
|
||||
jwtToken := httpRequest.Header.Get(s.cfg.JWTAuthHeaderName)
|
||||
if jwtToken == "" && s.cfg.JWTAuthURLLogin {
|
||||
jwtToken := httpRequest.Header.Get(s.cfg.JWTAuth.HeaderName)
|
||||
if jwtToken == "" && s.cfg.JWTAuth.URLLogin {
|
||||
jwtToken = httpRequest.URL.Query().Get("auth_token")
|
||||
}
|
||||
// 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 {
|
||||
if !s.cfg.JWTAuthEnabled || s.cfg.JWTAuthHeaderName == "" {
|
||||
if !s.cfg.JWTAuth.Enabled || s.cfg.JWTAuth.HeaderName == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -171,11 +174,11 @@ func (s *JWT) Priority() uint {
|
||||
const roleGrafanaAdmin = "GrafanaAdmin"
|
||||
|
||||
func (s *JWT) extractRoleAndAdmin(claims map[string]any) (org.RoleType, bool) {
|
||||
if s.cfg.JWTAuthRoleAttributePath == "" {
|
||||
if s.cfg.JWTAuth.RoleAttributePath == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
role, err := searchClaimsForStringAttr(s.cfg.JWTAuthRoleAttributePath, claims)
|
||||
role, err := util.SearchJSONForStringAttr(s.cfg.JWTAuth.RoleAttributePath, claims)
|
||||
if err != nil || role == "" {
|
||||
return "", false
|
||||
}
|
||||
@ -186,33 +189,10 @@ func (s *JWT) extractRoleAndAdmin(claims map[string]any) (org.RoleType, bool) {
|
||||
return org.RoleType(role), false
|
||||
}
|
||||
|
||||
func searchClaimsForStringAttr(attributePath string, claims map[string]any) (string, error) {
|
||||
val, err := searchClaimsForAttr(attributePath, claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
func (s *JWT) extractGroups(claims map[string]any) ([]string, error) {
|
||||
if s.cfg.JWTAuth.GroupsAttributePath == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
strVal, ok := val.(string)
|
||||
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
|
||||
return util.SearchJSONForStringSliceAttr(s.cfg.JWTAuth.GroupsAttributePath, claims)
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/login"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
func stringPtr(s string) *string {
|
||||
@ -22,22 +23,23 @@ func stringPtr(s string) *string {
|
||||
}
|
||||
|
||||
func TestAuthenticateJWT(t *testing.T) {
|
||||
jwtService := &jwt.FakeJWTService{
|
||||
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
|
||||
},
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
jwtHeaderName := "X-Forwarded-User"
|
||||
wantID := &authn.Identity{
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
wantID *authn.Identity
|
||||
verifyProvider func(context.Context, string) (jwt.JWTClaims, error)
|
||||
cfg *setting.Cfg
|
||||
}{
|
||||
{
|
||||
name: "Valid Use case with group path",
|
||||
wantID: &authn.Identity{
|
||||
OrgID: 0,
|
||||
OrgName: "",
|
||||
OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleAdmin},
|
||||
Groups: []string{"foo", "bar"},
|
||||
ID: "",
|
||||
Login: "eai-doe",
|
||||
Name: "Eai Doe",
|
||||
@ -53,25 +55,102 @@ func TestAuthenticateJWT(t *testing.T) {
|
||||
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{
|
||||
JWTAuthEnabled: true,
|
||||
JWTAuthHeaderName: jwtHeaderName,
|
||||
JWTAuthEmailClaim: "email",
|
||||
JWTAuthUsernameClaim: "preferred_username",
|
||||
JWTAuthAutoSignUp: true,
|
||||
JWTAuthAllowAssignGrafanaAdmin: true,
|
||||
JWTAuthRoleAttributeStrict: true,
|
||||
JWTAuthRoleAttributePath: "roles",
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
jwtService := &jwt.FakeJWTService{
|
||||
VerifyProvider: tc.verifyProvider,
|
||||
}
|
||||
jwtClient := ProvideJWT(jwtService, cfg)
|
||||
|
||||
jwtClient := ProvideJWT(jwtService, tc.cfg)
|
||||
validHTTPReq := &http.Request{
|
||||
Header: map[string][]string{
|
||||
jwtHeaderName: {"sample-token"}},
|
||||
@ -84,10 +163,13 @@ func TestAuthenticateJWT(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.EqualValues(t, wantID, id, fmt.Sprintf("%+v", id))
|
||||
assert.EqualValues(t, tc.wantID, id, fmt.Sprintf("%+v", id))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTClaimConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
jwtService := &jwt.FakeJWTService{
|
||||
VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) {
|
||||
return jwt.JWTClaims{
|
||||
@ -102,30 +184,19 @@ func TestJWTClaimConfig(t *testing.T) {
|
||||
|
||||
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
|
||||
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o"
|
||||
|
||||
type Dictionary map[string]any
|
||||
|
||||
type testCase struct {
|
||||
desc string
|
||||
claimsConfigurations []Dictionary
|
||||
claimsConfigurations []util.DynMap
|
||||
valid bool
|
||||
}
|
||||
|
||||
testCases := []testCase{
|
||||
{
|
||||
desc: "JWT configuration with email and username claims",
|
||||
claimsConfigurations: []Dictionary{
|
||||
claimsConfigurations: []util.DynMap{
|
||||
{
|
||||
"JWTAuthEmailClaim": true,
|
||||
"JWTAuthUsernameClaim": true,
|
||||
@ -135,7 +206,7 @@ func TestJWTClaimConfig(t *testing.T) {
|
||||
},
|
||||
{
|
||||
desc: "JWT configuration with email claim",
|
||||
claimsConfigurations: []Dictionary{
|
||||
claimsConfigurations: []util.DynMap{
|
||||
{
|
||||
"JWTAuthEmailClaim": true,
|
||||
"JWTAuthUsernameClaim": false,
|
||||
@ -145,7 +216,7 @@ func TestJWTClaimConfig(t *testing.T) {
|
||||
},
|
||||
{
|
||||
desc: "JWT configuration with username claim",
|
||||
claimsConfigurations: []Dictionary{
|
||||
claimsConfigurations: []util.DynMap{
|
||||
{
|
||||
"JWTAuthEmailClaim": false,
|
||||
"JWTAuthUsernameClaim": true,
|
||||
@ -155,7 +226,7 @@ func TestJWTClaimConfig(t *testing.T) {
|
||||
},
|
||||
{
|
||||
desc: "JWT configuration without email and username claims",
|
||||
claimsConfigurations: []Dictionary{
|
||||
claimsConfigurations: []util.DynMap{
|
||||
{
|
||||
"JWTAuthEmailClaim": false,
|
||||
"JWTAuthUsernameClaim": false,
|
||||
@ -166,19 +237,31 @@ func TestJWTClaimConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
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 {
|
||||
cfg.JWTAuthEmailClaim = ""
|
||||
cfg.JWTAuthUsernameClaim = ""
|
||||
cfg.JWTAuth.EmailClaim = ""
|
||||
cfg.JWTAuth.UsernameClaim = ""
|
||||
|
||||
if claims["JWTAuthEmailClaim"] == true {
|
||||
cfg.JWTAuthEmailClaim = "email"
|
||||
cfg.JWTAuth.EmailClaim = "email"
|
||||
}
|
||||
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{
|
||||
@ -195,10 +278,12 @@ func TestJWTClaimConfig(t *testing.T) {
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTTest(t *testing.T) {
|
||||
t.Parallel()
|
||||
jwtService := &jwt.FakeJWTService{}
|
||||
jwtHeaderName := "X-Forwarded-User"
|
||||
// #nosec G101 -- This is dummy/test token
|
||||
@ -280,14 +365,18 @@ func TestJWTTest(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := &setting.Cfg{
|
||||
JWTAuthEnabled: true,
|
||||
JWTAuthURLLogin: tc.urlLogin,
|
||||
JWTAuthHeaderName: tc.cfgHeaderName,
|
||||
JWTAuthAutoSignUp: true,
|
||||
JWTAuthAllowAssignGrafanaAdmin: true,
|
||||
JWTAuthRoleAttributeStrict: true,
|
||||
JWTAuth: setting.AuthJWTSettings{
|
||||
Enabled: true,
|
||||
URLLogin: tc.urlLogin,
|
||||
HeaderName: tc.cfgHeaderName,
|
||||
AutoSignUp: true,
|
||||
AllowAssignGrafanaAdmin: true,
|
||||
RoleAttributeStrict: true,
|
||||
},
|
||||
}
|
||||
jwtClient := ProvideJWT(jwtService, cfg)
|
||||
httpReq := &http.Request{
|
||||
@ -308,6 +397,7 @@ func TestJWTTest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestJWTStripParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
jwtService := &jwt.FakeJWTService{
|
||||
VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) {
|
||||
return jwt.JWTClaims{
|
||||
@ -323,15 +413,17 @@ func TestJWTStripParam(t *testing.T) {
|
||||
jwtHeaderName := "X-Forwarded-User"
|
||||
|
||||
cfg := &setting.Cfg{
|
||||
JWTAuthEnabled: true,
|
||||
JWTAuthHeaderName: jwtHeaderName,
|
||||
JWTAuthAutoSignUp: true,
|
||||
JWTAuthAllowAssignGrafanaAdmin: true,
|
||||
JWTAuthURLLogin: true,
|
||||
JWTAuthRoleAttributeStrict: false,
|
||||
JWTAuthRoleAttributePath: "roles",
|
||||
JWTAuthEmailClaim: "email",
|
||||
JWTAuthUsernameClaim: "preferred_username",
|
||||
JWTAuth: setting.AuthJWTSettings{
|
||||
Enabled: true,
|
||||
HeaderName: jwtHeaderName,
|
||||
AutoSignUp: true,
|
||||
AllowAssignGrafanaAdmin: true,
|
||||
URLLogin: true,
|
||||
RoleAttributeStrict: false,
|
||||
RoleAttributePath: "roles",
|
||||
EmailClaim: "email",
|
||||
UsernameClaim: "preferred_username",
|
||||
},
|
||||
}
|
||||
|
||||
// #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")
|
||||
|
||||
// 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" {
|
||||
list.Items = append(list.Items, cfg.JWTAuthHeaderName)
|
||||
if cfg.JWTAuth.Enabled && cfg.JWTAuth.HeaderName != "" && cfg.JWTAuth.HeaderName != "Authorization" {
|
||||
list.Items = append(list.Items, cfg.JWTAuth.HeaderName)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
cfg := setting.NewCfg()
|
||||
cfg.JWTAuthEnabled = true
|
||||
cfg.JWTAuthHeaderName = "jwt-header"
|
||||
cfg.JWTAuth.Enabled = true
|
||||
cfg.JWTAuth.HeaderName = "jwt-header"
|
||||
cfg.AuthProxyEnabled = true
|
||||
cfg.AuthProxyHeaderName = "proxy-header"
|
||||
cfg.AuthProxyHeaders = map[string]string{
|
||||
|
@ -76,7 +76,7 @@ func IsExternallySynced(cfg *setting.Cfg, authModule string, oauthInfo *social.O
|
||||
case LDAPAuthModule:
|
||||
return !cfg.LDAPSkipOrgRoleSync
|
||||
case JWTModule:
|
||||
return !cfg.JWTAuthSkipOrgRoleSync
|
||||
return !cfg.JWTAuth.SkipOrgRoleSync
|
||||
}
|
||||
// then check the rest of the oauth providers
|
||||
// FIXME: remove this once we remove the setting
|
||||
@ -104,7 +104,7 @@ func IsGrafanaAdminExternallySynced(cfg *setting.Cfg, oauthInfo *social.OAuthInf
|
||||
|
||||
switch authModule {
|
||||
case JWTModule:
|
||||
return cfg.JWTAuthAllowAssignGrafanaAdmin
|
||||
return cfg.JWTAuth.AllowAssignGrafanaAdmin
|
||||
case SAMLAuthModule:
|
||||
return cfg.SAMLRoleValuesGrafanaAdmin != ""
|
||||
case LDAPAuthModule:
|
||||
@ -121,7 +121,7 @@ func IsProviderEnabled(cfg *setting.Cfg, authModule string, oauthInfo *social.OA
|
||||
case LDAPAuthModule:
|
||||
return cfg.LDAPAuthEnabled
|
||||
case JWTModule:
|
||||
return cfg.JWTAuthEnabled
|
||||
return cfg.JWTAuth.Enabled
|
||||
case GoogleAuthModule, OktaAuthModule, AzureADAuthModule, GitLabAuthModule, GithubAuthModule, GrafanaComAuthModule, GenericOAuthModule:
|
||||
if oauthInfo == nil {
|
||||
return false
|
||||
|
@ -3,9 +3,10 @@ package login
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/grafana/grafana/pkg/login/social"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsExternallySynced(t *testing.T) {
|
||||
@ -82,20 +83,20 @@ func TestIsExternallySynced(t *testing.T) {
|
||||
// jwt
|
||||
{
|
||||
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,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
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,
|
||||
expected: false,
|
||||
},
|
||||
// IsProvider test
|
||||
{
|
||||
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,
|
||||
expected: false,
|
||||
},
|
||||
|
@ -267,24 +267,7 @@ type Cfg struct {
|
||||
OAuthCookieMaxAge int
|
||||
OAuthAllowInsecureEmailLookup bool
|
||||
|
||||
// JWT Auth
|
||||
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
|
||||
|
||||
JWTAuth AuthJWTSettings
|
||||
// Extended JWT Auth
|
||||
ExtendedJWTAuthEnabled bool
|
||||
ExtendedJWTExpectIssuer string
|
||||
@ -1195,6 +1178,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
|
||||
cfg.readLDAPConfig()
|
||||
cfg.handleAWSConfig()
|
||||
cfg.readAzureSettings()
|
||||
cfg.readAuthJWTSettings()
|
||||
cfg.readSessionConfig()
|
||||
if err := cfg.readSmtpSettings(); err != nil {
|
||||
return err
|
||||
@ -1608,25 +1592,6 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) {
|
||||
authBasic := iniFile.Section("auth.basic")
|
||||
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
|
||||
authExtendedJWT := cfg.SectionWithEnvOverrides("auth.extended_jwt")
|
||||
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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/jmespath/go-jmespath"
|
||||
|
||||
"github.com/grafana/grafana/pkg/util/errutil"
|
||||
)
|
||||
|
||||
// DynMap defines a dynamic map interface.
|
||||
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