[v11.0.x] JWT: Find login and email claims with JMESPATH (#86357)

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

(cherry picked from commit e4250a72db)

Co-authored-by: linoman <2051016+linoman@users.noreply.github.com>
pull/86371/head
grafana-delivery-bot[bot] 1 year ago committed by GitHub
parent ca94886e18
commit 6f6667d89c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      conf/defaults.ini
  2. 2
      conf/sample.ini
  3. 26
      docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md
  4. 4
      pkg/services/auth/jwt/auth.go
  5. 14
      pkg/services/auth/jwt/jwt.go
  6. 2
      pkg/services/auth/jwt/validation.go
  7. 13
      pkg/services/authn/clients/jwt.go
  8. 76
      pkg/services/authn/clients/jwt_test.go
  9. 4
      pkg/setting/setting_jwt.go

@ -852,6 +852,8 @@ enable_login_token = false
header_name = header_name =
email_claim = email_claim =
username_claim = username_claim =
email_attribute_path =
username_attribute_path =
jwk_set_url = jwk_set_url =
jwk_set_file = jwk_set_file =
cache_ttl = 60m cache_ttl = 60m

@ -775,6 +775,8 @@
;header_name = X-JWT-Assertion ;header_name = X-JWT-Assertion
;email_claim = sub ;email_claim = sub
;username_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_url = https://foo.bar/.well-known/jwks.json
;jwk_set_file = /path/to/jwks.json ;jwk_set_file = /path/to/jwks.json
;cache_ttl = 60m ;cache_ttl = 60m

@ -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. 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 ## Iframe Embedding
If you want to embed Grafana in an iframe while maintaining user identity and role checks, If you want to embed Grafana in an iframe while maintaining user identity and role checks,

@ -65,7 +65,7 @@ func sanitizeJWT(jwtToken string) string {
return strings.ReplaceAll(jwtToken, string(base64.StdPadding), "") 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") s.log.Debug("Parsing JSON Web Token")
strToken = sanitizeJWT(strToken) 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") s.log.Debug("Trying to verify JSON Web Token using a key")
var claims JWTClaims var claims map[string]any
for _, key := range keys { for _, key := range keys {
if err = token.Claims(key, &claims); err == nil { if err = token.Claims(key, &claims); err == nil {
break break

@ -2,28 +2,24 @@ package jwt
import ( import (
"context" "context"
"github.com/grafana/grafana/pkg/util"
) )
type JWTClaims util.DynMap
type JWTService interface { type JWTService interface {
Verify(ctx context.Context, strToken string) (JWTClaims, error) Verify(ctx context.Context, strToken string) (map[string]any, error)
} }
type FakeJWTService struct { 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) return s.VerifyProvider(ctx, token)
} }
func NewFakeJWTService() *FakeJWTService { func NewFakeJWTService() *FakeJWTService {
return &FakeJWTService{ return &FakeJWTService{
VerifyProvider: func(ctx context.Context, token string) (JWTClaims, error) { VerifyProvider: func(ctx context.Context, token string) (map[string]any, error) {
return JWTClaims{}, nil return map[string]any{}, nil
}, },
} }
} }

@ -52,7 +52,7 @@ func (s *AuthService) initClaimExpectations() error {
return nil return nil
} }
func (s *AuthService) validateClaims(claims JWTClaims) error { func (s *AuthService) validateClaims(claims map[string]any) error {
var registeredClaims jwt.Claims var registeredClaims jwt.Claims
for key, value := range claims { for key, value := range claims {
switch key { switch key {

@ -78,10 +78,23 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi
if key := s.cfg.JWTAuth.UsernameClaim; 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
} 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 != "" { 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
} 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 != "" { if name, _ := claims["name"].(string); name != "" {

@ -30,7 +30,7 @@ func TestAuthenticateJWT(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
wantID *authn.Identity wantID *authn.Identity
verifyProvider func(context.Context, string) (jwt.JWTClaims, error) verifyProvider func(context.Context, string) (map[string]any, error)
cfg *setting.Cfg cfg *setting.Cfg
}{ }{
{ {
@ -63,8 +63,8 @@ func TestAuthenticateJWT(t *testing.T) {
}, },
}, },
}, },
verifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { verifyProvider: func(context.Context, string) (map[string]any, error) {
return jwt.JWTClaims{ return map[string]any{
"sub": "1234567890", "sub": "1234567890",
"email": "eai.doe@cor.po", "email": "eai.doe@cor.po",
"preferred_username": "eai-doe", "preferred_username": "eai-doe",
@ -117,8 +117,8 @@ func TestAuthenticateJWT(t *testing.T) {
}, },
}, },
}, },
verifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { verifyProvider: func(context.Context, string) (map[string]any, error) {
return jwt.JWTClaims{ return map[string]any{
"sub": "1234567890", "sub": "1234567890",
"email": "eai.doe@cor.po", "email": "eai.doe@cor.po",
"preferred_username": "eai-doe", "preferred_username": "eai-doe",
@ -171,8 +171,8 @@ func TestAuthenticateJWT(t *testing.T) {
func TestJWTClaimConfig(t *testing.T) { func TestJWTClaimConfig(t *testing.T) {
t.Parallel() t.Parallel()
jwtService := &jwt.FakeJWTService{ jwtService := &jwt.FakeJWTService{
VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { VerifyProvider: func(context.Context, string) (map[string]any, error) {
return jwt.JWTClaims{ return map[string]any{
"sub": "1234567890", "sub": "1234567890",
"email": "eai.doe@cor.po", "email": "eai.doe@cor.po",
"preferred_username": "eai-doe", "preferred_username": "eai-doe",
@ -399,8 +399,8 @@ func TestJWTTest(t *testing.T) {
func TestJWTStripParam(t *testing.T) { func TestJWTStripParam(t *testing.T) {
t.Parallel() t.Parallel()
jwtService := &jwt.FakeJWTService{ jwtService := &jwt.FakeJWTService{
VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { VerifyProvider: func(context.Context, string) (map[string]any, error) {
return jwt.JWTClaims{ return map[string]any{
"sub": "1234567890", "sub": "1234567890",
"email": "eai.doe@cor.po", "email": "eai.doe@cor.po",
"preferred_username": "eai-doe", "preferred_username": "eai-doe",
@ -442,3 +442,61 @@ func TestJWTStripParam(t *testing.T) {
// auth_token should be removed from the query string // auth_token should be removed from the query string
assert.Equal(t, "other_param=other_value", httpReq.URL.RawQuery) 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)
}

@ -21,6 +21,8 @@ type AuthJWTSettings struct {
AllowAssignGrafanaAdmin bool AllowAssignGrafanaAdmin bool
SkipOrgRoleSync bool SkipOrgRoleSync bool
GroupsAttributePath string GroupsAttributePath string
EmailAttributePath string
UsernameAttributePath string
} }
func (cfg *Cfg) readAuthJWTSettings() { func (cfg *Cfg) readAuthJWTSettings() {
@ -43,6 +45,8 @@ func (cfg *Cfg) readAuthJWTSettings() {
jwtSettings.AllowAssignGrafanaAdmin = authJWT.Key("allow_assign_grafana_admin").MustBool(false) jwtSettings.AllowAssignGrafanaAdmin = authJWT.Key("allow_assign_grafana_admin").MustBool(false)
jwtSettings.SkipOrgRoleSync = authJWT.Key("skip_org_role_sync").MustBool(false) jwtSettings.SkipOrgRoleSync = authJWT.Key("skip_org_role_sync").MustBool(false)
jwtSettings.GroupsAttributePath = valueAsString(authJWT, "groups_attribute_path", "") jwtSettings.GroupsAttributePath = valueAsString(authJWT, "groups_attribute_path", "")
jwtSettings.EmailAttributePath = valueAsString(authJWT, "email_attribute_path", "")
jwtSettings.UsernameAttributePath = valueAsString(authJWT, "username_attribute_path", "")
cfg.JWTAuth = jwtSettings cfg.JWTAuth = jwtSettings
} }

Loading…
Cancel
Save