From e4250a72dbf2ae4384cc6a575884f67db30e0575 Mon Sep 17 00:00:00 2001 From: linoman <2051016+linoman@users.noreply.github.com> Date: Thu, 28 Mar 2024 10:25:26 -0600 Subject: [PATCH] JWT: Find login and email claims with JMESPATH (#85305) * add function to static function to static service * find email and login claims with jmespath * rename configuration files * Replace JWTClaims struct for map * check for subclaims error --- conf/defaults.ini | 2 + conf/sample.ini | 2 + .../configure-authentication/jwt/index.md | 26 +++++++ pkg/services/auth/jwt/auth.go | 4 +- pkg/services/auth/jwt/jwt.go | 14 ++-- pkg/services/auth/jwt/validation.go | 2 +- pkg/services/authn/clients/jwt.go | 13 ++++ pkg/services/authn/clients/jwt_test.go | 76 ++++++++++++++++--- pkg/setting/setting_jwt.go | 4 + 9 files changed, 122 insertions(+), 21 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 6ba5fcb5941..a6816edc769 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -852,6 +852,8 @@ enable_login_token = false header_name = email_claim = username_claim = +email_attribute_path = +username_attribute_path = jwk_set_url = jwk_set_file = cache_ttl = 60m diff --git a/conf/sample.ini b/conf/sample.ini index ecb12e32c72..f315784fed2 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -775,6 +775,8 @@ ;header_name = X-JWT-Assertion ;email_claim = sub ;username_claim = sub +;email_attribute_path = jmespath.email +;username_attribute_path = jmespath.username ;jwk_set_url = https://foo.bar/.well-known/jwks.json ;jwk_set_file = /path/to/jwks.json ;cache_ttl = 60m diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md index b182ba873e2..ba7e325475c 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md @@ -62,6 +62,32 @@ email_claim = sub If `auto_sign_up` is enabled, then the `sub` claim is used as the "external Auth ID". The `name` claim is used as the user's full name if it is present. +Additionally, if the login username or the email claims are nested inside the JWT structure, you can specify the path to the attributes using the `username_attribute_path` and `email_attribute_path` configuration options using the JMESPath syntax. + +JWT structure example. + +```json +{ + "user": { + "UID": "1234567890", + "name": "John Doe", + "username": "johndoe", + "emails": ["personal@email.com", "professional@email.com"] + } +} +``` + +```ini +# [auth.jwt] +# ... + +# Specify a nested attribute to use as a username to sign in. +username_attribute_path = user.username # user's login is johndoe + +# Specify a nested attribute to use as an email to sign in. +email_attribute_path = user.emails[1] # user's email is professional@email.com +``` + ## Iframe Embedding If you want to embed Grafana in an iframe while maintaining user identity and role checks, diff --git a/pkg/services/auth/jwt/auth.go b/pkg/services/auth/jwt/auth.go index 37e0a7fa373..aaa15b98321 100644 --- a/pkg/services/auth/jwt/auth.go +++ b/pkg/services/auth/jwt/auth.go @@ -65,7 +65,7 @@ func sanitizeJWT(jwtToken string) string { return strings.ReplaceAll(jwtToken, string(base64.StdPadding), "") } -func (s *AuthService) Verify(ctx context.Context, strToken string) (JWTClaims, error) { +func (s *AuthService) Verify(ctx context.Context, strToken string) (map[string]any, error) { s.log.Debug("Parsing JSON Web Token") strToken = sanitizeJWT(strToken) @@ -84,7 +84,7 @@ func (s *AuthService) Verify(ctx context.Context, strToken string) (JWTClaims, e s.log.Debug("Trying to verify JSON Web Token using a key") - var claims JWTClaims + var claims map[string]any for _, key := range keys { if err = token.Claims(key, &claims); err == nil { break diff --git a/pkg/services/auth/jwt/jwt.go b/pkg/services/auth/jwt/jwt.go index c1175b5c72d..f070772ac5d 100644 --- a/pkg/services/auth/jwt/jwt.go +++ b/pkg/services/auth/jwt/jwt.go @@ -2,28 +2,24 @@ package jwt import ( "context" - - "github.com/grafana/grafana/pkg/util" ) -type JWTClaims util.DynMap - type JWTService interface { - Verify(ctx context.Context, strToken string) (JWTClaims, error) + Verify(ctx context.Context, strToken string) (map[string]any, error) } type FakeJWTService struct { - VerifyProvider func(context.Context, string) (JWTClaims, error) + VerifyProvider func(context.Context, string) (map[string]any, error) } -func (s *FakeJWTService) Verify(ctx context.Context, token string) (JWTClaims, error) { +func (s *FakeJWTService) Verify(ctx context.Context, token string) (map[string]any, error) { return s.VerifyProvider(ctx, token) } func NewFakeJWTService() *FakeJWTService { return &FakeJWTService{ - VerifyProvider: func(ctx context.Context, token string) (JWTClaims, error) { - return JWTClaims{}, nil + VerifyProvider: func(ctx context.Context, token string) (map[string]any, error) { + return map[string]any{}, nil }, } } diff --git a/pkg/services/auth/jwt/validation.go b/pkg/services/auth/jwt/validation.go index 4fe5b88119f..d6c347de01e 100644 --- a/pkg/services/auth/jwt/validation.go +++ b/pkg/services/auth/jwt/validation.go @@ -52,7 +52,7 @@ func (s *AuthService) initClaimExpectations() error { return nil } -func (s *AuthService) validateClaims(claims JWTClaims) error { +func (s *AuthService) validateClaims(claims map[string]any) error { var registeredClaims jwt.Claims for key, value := range claims { switch key { diff --git a/pkg/services/authn/clients/jwt.go b/pkg/services/authn/clients/jwt.go index e789f9153a0..4c741fd2ab0 100644 --- a/pkg/services/authn/clients/jwt.go +++ b/pkg/services/authn/clients/jwt.go @@ -78,10 +78,23 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi if key := s.cfg.JWTAuth.UsernameClaim; key != "" { id.Login, _ = claims[key].(string) id.ClientParams.LookUpParams.Login = &id.Login + } else if key := s.cfg.JWTAuth.UsernameAttributePath; key != "" { + id.Login, err = util.SearchJSONForStringAttr(s.cfg.JWTAuth.UsernameAttributePath, claims) + if err != nil { + return nil, err + } + id.ClientParams.LookUpParams.Login = &id.Login } + if key := s.cfg.JWTAuth.EmailClaim; key != "" { id.Email, _ = claims[key].(string) id.ClientParams.LookUpParams.Email = &id.Email + } else if key := s.cfg.JWTAuth.EmailAttributePath; key != "" { + id.Email, err = util.SearchJSONForStringAttr(s.cfg.JWTAuth.EmailAttributePath, claims) + if err != nil { + return nil, err + } + id.ClientParams.LookUpParams.Email = &id.Email } if name, _ := claims["name"].(string); name != "" { diff --git a/pkg/services/authn/clients/jwt_test.go b/pkg/services/authn/clients/jwt_test.go index f704a691189..d9476f37320 100644 --- a/pkg/services/authn/clients/jwt_test.go +++ b/pkg/services/authn/clients/jwt_test.go @@ -30,7 +30,7 @@ func TestAuthenticateJWT(t *testing.T) { testCases := []struct { name string wantID *authn.Identity - verifyProvider func(context.Context, string) (jwt.JWTClaims, error) + verifyProvider func(context.Context, string) (map[string]any, error) cfg *setting.Cfg }{ { @@ -63,8 +63,8 @@ func TestAuthenticateJWT(t *testing.T) { }, }, }, - verifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { - return jwt.JWTClaims{ + verifyProvider: func(context.Context, string) (map[string]any, error) { + return map[string]any{ "sub": "1234567890", "email": "eai.doe@cor.po", "preferred_username": "eai-doe", @@ -117,8 +117,8 @@ func TestAuthenticateJWT(t *testing.T) { }, }, }, - verifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { - return jwt.JWTClaims{ + verifyProvider: func(context.Context, string) (map[string]any, error) { + return map[string]any{ "sub": "1234567890", "email": "eai.doe@cor.po", "preferred_username": "eai-doe", @@ -171,8 +171,8 @@ func TestAuthenticateJWT(t *testing.T) { func TestJWTClaimConfig(t *testing.T) { t.Parallel() jwtService := &jwt.FakeJWTService{ - VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { - return jwt.JWTClaims{ + VerifyProvider: func(context.Context, string) (map[string]any, error) { + return map[string]any{ "sub": "1234567890", "email": "eai.doe@cor.po", "preferred_username": "eai-doe", @@ -399,8 +399,8 @@ 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{ + VerifyProvider: func(context.Context, string) (map[string]any, error) { + return map[string]any{ "sub": "1234567890", "email": "eai.doe@cor.po", "preferred_username": "eai-doe", @@ -442,3 +442,61 @@ func TestJWTStripParam(t *testing.T) { // auth_token should be removed from the query string assert.Equal(t, "other_param=other_value", httpReq.URL.RawQuery) } + +func TestJWTSubClaimsConfig(t *testing.T) { + t.Parallel() + + // #nosec G101 -- This is a dummy/test token + token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2ZXIiOiIxLjAiLCJpc3MiOiJodHRwczovL2F6dXJlZG9tYWlubmFtZS5iMmNsb2dpbi5jb20vNjIwYjI2MzQtYmI4OC00MzdiLTgwYWQtYWM0YTkwZGZkZTkxL3YyLjAvIiwic3ViIjoiOWI4OTg5MDgtMWFlYy00NDc1LTljNDgtNzg1MWQyNjVkZGIxIiwiYXVkIjoiYmEyNzM0NDktMmZiNS00YTRhLTlmODItYTA2MTRhM2MxODQ1IiwiZXhwIjoxNzExNTYwMDcxLCJub25jZSI6ImRlZmF1bHROb25jZSIsImlhdCI6MTcxMTU1NjQ3MSwiYXV0aF90aW1lIjoxNzExNTU2NDcxLCJuYW1lIjoibmFtZV9vZl90aGVfdXNlciIsImdpdmVuX25hbWUiOiJVc2VyTmFtZSIsImZhbWlseV9uYW1lIjoiVXNlclN1cm5hbWUiLCJlbWFpbHMiOlsibWFpbmVtYWlsK2V4dHJhZW1haWwwNUBnbWFpbC5jb20iLCJtYWluZW1haWwrZXh0cmFlbWFpbDA0QGdtYWlsLmNvbSIsIm1haW5lbWFpbCtleHRyYWVtYWlsMDNAZ21haWwuY29tIiwibWFpbmVtYWlsK2V4dHJhZW1haWwwMkBnbWFpbC5jb20iLCJtYWluZW1haWwrZXh0cmFlbWFpbDAxQGdtYWlsLmNvbSIsIm1haW5lbWFpbEBnbWFpbC5jb20iXSwidGZwIjoiQjJDXzFfdXNlcmZsb3ciLCJuYmYiOjE3MTE1NTY0NzF9.qpN3upxUB5CTJ7kmYPHFuhlwG95vdQqJaDDC_8KJFZ8" + jwtHeaderName := "X-Forwarded-User" + response := map[string]any{ + "ver": "1.0", + "iss": "https://azuredomainname.b2clogin.com/620b2634-bb88-437b-80ad-ac4a90dfde91/v2.0/", + "sub": "9b898908-1aec-4475-9c48-7851d265ddb1", + "aud": "ba273449-2fb5-4a4a-9f82-a0614a3c1845", + "exp": 1711560071, + "nonce": "defaultNonce", + "iat": 1711556471, + "auth_time": 1711556471, + "name": "name_of_the_user", + "given_name": "UserName", + "family_name": "UserSurname", + "emails": []string{ + "mainemail+extraemail04@gmail.com", + "mainemail+extraemail03@gmail.com", + "mainemail+extraemail02@gmail.com", + "mainemail+extraemail01@gmail.com", + "mainemail@gmail.com", + }, + "tfp": "B2C_1_userflow", + "nbf": 1711556471, + } + cfg := &setting.Cfg{ + JWTAuth: setting.AuthJWTSettings{ + HeaderName: jwtHeaderName, + EmailAttributePath: "emails[2]", + UsernameAttributePath: "name", + }, + } + httpReq := &http.Request{ + URL: &url.URL{RawQuery: "auth_token=" + token}, + Header: map[string][]string{ + jwtHeaderName: {token}}, + } + jwtService := &jwt.FakeJWTService{ + VerifyProvider: func(context.Context, string) (map[string]any, error) { + return response, nil + }, + } + + jwtClient := ProvideJWT(jwtService, cfg) + identity, err := jwtClient.Authenticate(context.Background(), &authn.Request{ + OrgID: 1, + HTTPRequest: httpReq, + Resp: nil, + }) + require.NoError(t, err) + require.Equal(t, "mainemail+extraemail02@gmail.com", identity.Email) + require.Equal(t, "name_of_the_user", identity.Name) + fmt.Println("identity.Email", identity.Email) +} diff --git a/pkg/setting/setting_jwt.go b/pkg/setting/setting_jwt.go index 1f6a672e526..ec645bbc4e7 100644 --- a/pkg/setting/setting_jwt.go +++ b/pkg/setting/setting_jwt.go @@ -21,6 +21,8 @@ type AuthJWTSettings struct { AllowAssignGrafanaAdmin bool SkipOrgRoleSync bool GroupsAttributePath string + EmailAttributePath string + UsernameAttributePath string } func (cfg *Cfg) readAuthJWTSettings() { @@ -43,6 +45,8 @@ func (cfg *Cfg) readAuthJWTSettings() { 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", "") + jwtSettings.EmailAttributePath = valueAsString(authJWT, "email_attribute_path", "") + jwtSettings.UsernameAttributePath = valueAsString(authJWT, "username_attribute_path", "") cfg.JWTAuth = jwtSettings }