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
16 changes: 16 additions & 0 deletions pkg/services/auth/jwt/auth.go
Expand Up @@ -4,9 +4,11 @@ import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"

"github.com/go-jose/go-jose/v3/jwt"
"github.com/jmespath/go-jmespath"

"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/remotecache"
Expand Down Expand Up @@ -118,3 +120,17 @@ func HasSubClaim(jwtToken string) bool {

return claims.Subject != ""
}

func FindSubClaims(subclaim string, jsonMap any) (string, error) {
linoman marked this conversation as resolved.
Show resolved Hide resolved
subclaimFound, err := jmespath.Search(subclaim, jsonMap)

if err != nil {
return "", err
}

if subclaimFound == nil {
return "", errors.New("subclaim not found")
}

return fmt.Sprintf("%s", subclaimFound), nil
}
82 changes: 82 additions & 0 deletions pkg/services/auth/jwt/auth_test.go
Expand Up @@ -444,3 +444,85 @@ func configurePKIXPublicKeyFile(t *testing.T, cfg *setting.Cfg) {

cfg.JWTAuth.KeyFile = file.Name()
}

func TestFindSubClaims(t *testing.T) {
t.Parallel()

tests := []struct {
name string
attributePath string
searchObject any
expectedResult string
expectedError bool
}{
{
name: "should find the email within an array of emails",
searchObject: map[string]any{
"emails": []string{
"username+extraemail00@gmail.com",
"username+extraemail01@gmail.com",
"username+extraemail02@gmail.com",
"username+extraemail03@gmail.com",
},
},
attributePath: "emails[2]",
expectedResult: "username+extraemail02@gmail.com",
},
{
name: "should find the username within a nested object",
searchObject: map[string]any{
"user": map[string]any{
"display_name": "John Doe",
"metadata": map[string]any{
"role": "admin",
"username": "johndoe",
},
},
"emails": []string{
"username+extraemail00@gmail.com",
"username+extraemail01@gmail.com",
"username+extraemail02@gmail.com",
"username+extraemail03@gmail.com",
},
},
attributePath: "user.metadata.username",
expectedResult: "johndoe",
},
{
name: "should return an error if the attribute path is empty",
searchObject: map[string]any{
"user": map[string]any{
"display_name": "John Doe",
"metadata": map[string]any{
"role": "admin",
"username": "johndoe",
},
},
},
attributePath: "",
expectedResult: "",
expectedError: true,
},
{
name: "should return an error if no property is found",
searchObject: map[string]any{
"property": "value",
},
attributePath: "nonexistent",
expectedError: true,
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
result, err := FindSubClaims(tt.attributePath, tt.searchObject)
if tt.expectedError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.expectedResult, result)
})
}
}
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.NameAttributePath; key != "" {
linoman marked this conversation as resolved.
Show resolved Hide resolved
id.Login, _ = authJWT.FindSubClaims(s.cfg.JWTAuth.NameAttributePath, 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, _ = authJWT.FindSubClaims(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]",
NameAttributePath: "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
NameAttributePath 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.NameAttributePath = valueAsString(authJWT, "name_attribute_path", "")

cfg.JWTAuth = jwtSettings
}