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
This commit is contained in:
linoman 2024-03-28 10:25:26 -06:00 committed by GitHub
parent 18f3c7188b
commit e4250a72db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 122 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 != "" {

View File

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

View File

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