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:
Jo 2024-02-09 16:35:58 +01:00 committed by GitHub
parent 32a1f3955a
commit 6f62d970e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 601 additions and 509 deletions

View File

@ -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

View File

@ -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

View File

@ -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
``` ```

View File

@ -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{

View File

@ -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]),

View File

@ -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{

View File

@ -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) {

View File

@ -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) {

View File

@ -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",

View File

@ -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)
} }

View File

@ -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
} }

View File

@ -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()
} }

View File

@ -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)

View File

@ -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,
} }
} }

View File

@ -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
} }

View File

@ -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))
} }

View File

@ -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

View File

@ -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

View File

@ -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
} }

View File

@ -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

View File

@ -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

View File

@ -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{

View File

@ -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

View File

@ -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,
}, },

View File

@ -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)

View 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
}

View File

@ -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
View 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)
})
}
}