Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JWT: Find login and email claims with JMESPATH #85305

Merged
merged 10 commits into from Mar 28, 2024
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
7 changes: 7 additions & 0 deletions pkg/services/authn/clients/jwt.go
Expand Up @@ -78,10 +78,17 @@ 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, _ = util.SearchJSONForStringAttr(s.cfg.JWTAuth.UsernameAttributePath, claims)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to handle potential errors for both of these

Copy link
Contributor Author

@linoman linoman Mar 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intentionally didn't handle the errors similar to what was already being done 3 lines before.

id.Login, _ = claims[key].(string)

They don't handle the errors because the identity validation expects username and email to be set.

If we handle errors for these 2, we might as well handle the missing elements for the other 2. WDYT @kalleep? How would you like them 4 to be handled?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@linoman The emptiness of id.Login and id.Email is checked at the end of this function:

if id.Login == "" && id.Email == "" {
s.log.FromContext(ctx).Debug("Failed to get an authentication claim from JWT",
"login", id.Login, "email", id.Email)
return nil, errJWTMissingClaim.Errorf("missing login and email claim in JWT")

However, I think we should still check, log and return the errors of util.SearchJSONForStringAttr as Kalle said.

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, _ = util.SearchJSONForStringAttr(s.cfg.JWTAuth.EmailAttributePath, claims)
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
}