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
28 changed files with 601 additions and 509 deletions

View File

@@ -131,7 +131,7 @@ func ProvideService(
}
}
if s.cfg.JWTAuthEnabled {
if s.cfg.JWTAuth.Enabled {
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["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

View File

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

View File

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

View File

@@ -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,72 +23,153 @@ 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{
OrgID: 0,
OrgName: "",
OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleAdmin},
ID: "",
Login: "eai-doe",
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,
LookUpParams: login.UserLookupParams{
UserID: nil,
Email: stringPtr("eai.doe@cor.po"),
Login: stringPtr("eai-doe"),
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",
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: 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",
}
jwtClient := ProvideJWT(jwtService, cfg)
validHTTPReq := &http.Request{
Header: map[string][]string{
jwtHeaderName: {"sample-token"}},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
jwtService := &jwt.FakeJWTService{
VerifyProvider: tc.verifyProvider,
}
id, err := jwtClient.Authenticate(context.Background(), &authn.Request{
OrgID: 1,
HTTPRequest: validHTTPReq,
Resp: nil,
})
require.NoError(t, err)
jwtClient := ProvideJWT(jwtService, tc.cfg)
validHTTPReq := &http.Request{
Header: map[string][]string{
jwtHeaderName: {"sample-token"}},
}
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) {
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,39 +237,53 @@ 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{
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) {
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