Skip to content

Commit

Permalink
[v11.0.x] JWT: Find login and email claims with JMESPATH (#86357)
Browse files Browse the repository at this point in the history
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 e4250a7)

Co-authored-by: linoman <2051016+linoman@users.noreply.github.com>
  • Loading branch information
grafana-delivery-bot[bot] and linoman committed Apr 16, 2024
1 parent ca94886 commit 6f6667d
Show file tree
Hide file tree
Showing 9 changed files with 122 additions and 21 deletions.
2 changes: 2 additions & 0 deletions conf/defaults.ini
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions conf/sample.ini
Expand Up @@ -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
Expand Down
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions pkg/services/auth/jwt/auth.go
Expand Up @@ -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)
Expand All @@ -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
Expand Down
14 changes: 5 additions & 9 deletions pkg/services/auth/jwt/jwt.go
Expand Up @@ -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
},
}
}
2 changes: 1 addition & 1 deletion pkg/services/auth/jwt/validation.go
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions pkg/services/authn/clients/jwt.go
Expand Up @@ -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 != "" {
Expand Down
76 changes: 67 additions & 9 deletions pkg/services/authn/clients/jwt_test.go
Expand Up @@ -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
}{
{
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}
4 changes: 4 additions & 0 deletions pkg/setting/setting_jwt.go
Expand Up @@ -21,6 +21,8 @@ type AuthJWTSettings struct {
AllowAssignGrafanaAdmin bool
SkipOrgRoleSync bool
GroupsAttributePath string
EmailAttributePath string
UsernameAttributePath string
}

func (cfg *Cfg) readAuthJWTSettings() {
Expand All @@ -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
}

0 comments on commit 6f6667d

Please sign in to comment.