diff --git a/conf/defaults.ini b/conf/defaults.ini index 1ff2d4612ee..c15a21eb6e6 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -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 diff --git a/conf/sample.ini b/conf/sample.ini index 66631fd8c6a..17ad690d76e 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -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 - diff --git a/devenv/docker/blocks/auth/jwt_proxy/readme.md b/devenv/docker/blocks/auth/jwt_proxy/readme.md index f3e32d147c4..823287f54e3 100644 --- a/devenv/docker/blocks/auth/jwt_proxy/readme.md +++ b/devenv/docker/blocks/auth/jwt_proxy/readme.md @@ -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 ``` diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index c449854c322..d40417f72f3 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -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{ diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index ce055ded32b..3016bd44c1e 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -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]), diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index 964eb23da87..e8b96c5b2fa 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -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{ diff --git a/pkg/login/social/connectors/common.go b/pkg/login/social/connectors/common.go index 93517fed981..96bf84020ba 100644 --- a/pkg/login/social/connectors/common.go +++ b/pkg/login/social/connectors/common.go @@ -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) { diff --git a/pkg/login/social/connectors/generic_oauth.go b/pkg/login/social/connectors/generic_oauth.go index a001aef73d4..a8ddd13fd2a 100644 --- a/pkg/login/social/connectors/generic_oauth.go +++ b/pkg/login/social/connectors/generic_oauth.go @@ -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) { diff --git a/pkg/login/social/connectors/generic_oauth_test.go b/pkg/login/social/connectors/generic_oauth_test.go index bf991f18712..3812a2d43bd 100644 --- a/pkg/login/social/connectors/generic_oauth_test.go +++ b/pkg/login/social/connectors/generic_oauth_test.go @@ -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", diff --git a/pkg/login/social/connectors/social_base.go b/pkg/login/social/connectors/social_base.go index b57d3c0dd5e..afe64097c4e 100644 --- a/pkg/login/social/connectors/social_base.go +++ b/pkg/login/social/connectors/social_base.go @@ -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) } diff --git a/pkg/services/auth/jwt/auth.go b/pkg/services/auth/jwt/auth.go index c2d317a056e..37e0a7fa373 100644 --- a/pkg/services/auth/jwt/auth.go +++ b/pkg/services/auth/jwt/auth.go @@ -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 } diff --git a/pkg/services/auth/jwt/auth_test.go b/pkg/services/auth/jwt/auth_test.go index fee5ac05b25..73d2106b72d 100644 --- a/pkg/services/auth/jwt/auth_test.go +++ b/pkg/services/auth/jwt/auth_test.go @@ -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() } diff --git a/pkg/services/auth/jwt/jwt.go b/pkg/services/auth/jwt/jwt.go index c0c7ddd4292..c1175b5c72d 100644 --- a/pkg/services/auth/jwt/jwt.go +++ b/pkg/services/auth/jwt/jwt.go @@ -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) diff --git a/pkg/services/auth/jwt/key_sets.go b/pkg/services/auth/jwt/key_sets.go index f981b21a591..5361f3b3fbb 100644 --- a/pkg/services/auth/jwt/key_sets.go +++ b/pkg/services/auth/jwt/key_sets.go @@ -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, } } diff --git a/pkg/services/auth/jwt/validation.go b/pkg/services/auth/jwt/validation.go index 260aa964f29..4fe5b88119f 100644 --- a/pkg/services/auth/jwt/validation.go +++ b/pkg/services/auth/jwt/validation.go @@ -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 } diff --git a/pkg/services/authn/authnimpl/service.go b/pkg/services/authn/authnimpl/service.go index a6bc4d451cd..fe65c436739 100644 --- a/pkg/services/authn/authnimpl/service.go +++ b/pkg/services/authn/authnimpl/service.go @@ -131,7 +131,7 @@ func ProvideService( } } - if s.cfg.JWTAuthEnabled { + if s.cfg.JWTAuth.Enabled { s.RegisterClient(clients.ProvideJWT(jwtService, cfg)) } diff --git a/pkg/services/authn/authnimpl/usage_stats.go b/pkg/services/authn/authnimpl/usage_stats.go index e2b15cf1ab4..b6ea1a95c6d 100644 --- a/pkg/services/authn/authnimpl/usage_stats.go +++ b/pkg/services/authn/authnimpl/usage_stats.go @@ -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 diff --git a/pkg/services/authn/authnimpl/usage_stats_test.go b/pkg/services/authn/authnimpl/usage_stats_test.go index ba59c1f1818..cded65c117e 100644 --- a/pkg/services/authn/authnimpl/usage_stats_test.go +++ b/pkg/services/authn/authnimpl/usage_stats_test.go @@ -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 diff --git a/pkg/services/authn/clients/jwt.go b/pkg/services/authn/clients/jwt.go index 9c628906baf..e789f9153a0 100644 --- a/pkg/services/authn/clients/jwt.go +++ b/pkg/services/authn/clients/jwt.go @@ -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) } diff --git a/pkg/services/authn/clients/jwt_test.go b/pkg/services/authn/clients/jwt_test.go index 95ce08467a1..f704a691189 100644 --- a/pkg/services/authn/clients/jwt_test.go +++ b/pkg/services/authn/clients/jwt_test.go @@ -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 diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index 6ea987300f6..7bb582c970e 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -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 diff --git a/pkg/services/contexthandler/contexthandler_test.go b/pkg/services/contexthandler/contexthandler_test.go index 47f5adc4816..cd6b1e1663b 100644 --- a/pkg/services/contexthandler/contexthandler_test.go +++ b/pkg/services/contexthandler/contexthandler_test.go @@ -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{ diff --git a/pkg/services/login/authinfo.go b/pkg/services/login/authinfo.go index f70dae8c869..4f3d95bd931 100644 --- a/pkg/services/login/authinfo.go +++ b/pkg/services/login/authinfo.go @@ -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 diff --git a/pkg/services/login/authinfo_test.go b/pkg/services/login/authinfo_test.go index bd84e471e83..39746ef8745 100644 --- a/pkg/services/login/authinfo_test.go +++ b/pkg/services/login/authinfo_test.go @@ -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, }, diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 92127cc6314..5d6ed89392d 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -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) diff --git a/pkg/setting/setting_jwt.go b/pkg/setting/setting_jwt.go new file mode 100644 index 00000000000..1f6a672e526 --- /dev/null +++ b/pkg/setting/setting_jwt.go @@ -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 +} diff --git a/pkg/util/json.go b/pkg/util/json.go index 7268ff93798..b1e7503bf51 100644 --- a/pkg/util/json.go +++ b/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 +} diff --git a/pkg/util/json_test.go b/pkg/util/json_test.go new file mode 100644 index 00000000000..232043a05f5 --- /dev/null +++ b/pkg/util/json_test.go @@ -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) + }) + } +}