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.

Additionaly, 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", "profesional@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 profesional@email.com
```

## Iframe Embedding

If you want to embed Grafana in an iframe while maintaining user identity and role checks,
Expand Down
12 changes: 12 additions & 0 deletions pkg/services/authn/clients/jwt.go
Expand Up @@ -2,6 +2,7 @@ package clients

import (
"context"
"encoding/json"
"net/http"
"strings"

Expand Down Expand Up @@ -75,13 +76,24 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi
SyncTeams: s.cfg.JWTAuth.GroupsAttributePath != "",
}}

serializedClaims, _ := json.Marshal(&claims)
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of marshaling/unmarshaling you can use map[string]any(claims) in the code.
Example:

id.Email, _ = util.SearchJSONForStringAttr(s.cfg.JWTAuth.EmailAttributePath, map[string]any(claims))

Because claims's type is JWTClaims which is a "redefinition of DynMap -> map[string]any, so because of this the underlying jmespath lib cannot work without the type conversion to map[string]any, because it checks for map[string]any, but it receives JWTClaims.

I don't think the custom type definitions (without any functions attached to those) provides any benefit here. So we could remove JWTClaims and use map[string]any instead to make the code simpler. @kalleep wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah I think it makes sense, either solution would work but I think I would prefer to remove the custom definition JwtClaims and just return map[string]any for the reasons @mgyongyosi stated

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.

As suggested, I have replaced JWTClaims with map[string]any and removed the conversions of claims to map. Looks much cleaner!

var data map[string]interface{}
_ = json.Unmarshal(serializedClaims, &data)

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, data)
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, data)
id.ClientParams.LookUpParams.Email = &id.Email
}

if name, _ := claims["name"].(string); name != "" {
Expand Down
58 changes: 58 additions & 0 deletions pkg/services/authn/clients/jwt_test.go
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 := jwt.JWTClaims{
"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) (jwt.JWTClaims, 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
}