mirror of
https://github.com/grafana/grafana.git
synced 2025-02-13 00:55:47 -06:00
OAuth: Support JMES path lookup when retrieving user email (#14683)
Add support for fetching e-mail with JMES path Signed-off-by: Bob Shannon <bobs@dropbox.com>
This commit is contained in:
parent
35b74a99a8
commit
056dbc7012
@ -366,6 +366,7 @@ client_id = some_id
|
||||
client_secret = some_secret
|
||||
scopes = user:email
|
||||
email_attribute_name = email:primary
|
||||
email_attribute_path =
|
||||
auth_url =
|
||||
token_url =
|
||||
api_url =
|
||||
|
@ -319,6 +319,8 @@
|
||||
;client_id = some_id
|
||||
;client_secret = some_secret
|
||||
;scopes = user:email,read:org
|
||||
;email_attribute_name = email:primary
|
||||
;email_attribute_path =
|
||||
;auth_url = https://foo.bar/login/oauth/authorize
|
||||
;token_url = https://foo.bar/login/oauth/access_token
|
||||
;api_url = https://foo.bar/user
|
||||
|
@ -40,9 +40,11 @@ Set `api_url` to the resource that returns [OpenID UserInfo](https://connect2id.
|
||||
Grafana will attempt to determine the user's e-mail address by querying the OAuth provider as described below in the following order until an e-mail address is found:
|
||||
|
||||
1. Check for the presence of an e-mail address via the `email` field encoded in the OAuth `id_token` parameter.
|
||||
2. Check for the presence of an e-mail address in the `attributes` map encoded in the OAuth `id_token` parameter. By default Grafana will perform a lookup into the attributes map using the `email:primary` key, however, this is configurable and can be adjusted by using the `email_attribute_name` configuration option.
|
||||
3. Query the `/emails` endpoint of the OAuth provider's API (configured with `api_url`) and check for the presence of an e-mail address marked as a primary address.
|
||||
4. If no e-mail address is found in steps (1-3), then the e-mail address of the user is set to the empty string.
|
||||
2. Check for the presence of an e-mail address using the [JMES path](http://jmespath.org/examples.html) specified via the `email_attribute_path` configuration option. The JSON used for the path lookup is the HTTP response obtained from querying the UserInfo endpoint specified via the `api_url` configuration option.
|
||||
**Note**: Only available in Grafana v6.4+.
|
||||
3. Check for the presence of an e-mail address in the `attributes` map encoded in the OAuth `id_token` parameter. By default Grafana will perform a lookup into the attributes map using the `email:primary` key, however, this is configurable and can be adjusted by using the `email_attribute_name` configuration option.
|
||||
4. Query the `/emails` endpoint of the OAuth provider's API (configured with `api_url`) and check for the presence of an e-mail address marked as a primary address.
|
||||
5. If no e-mail address is found in steps (1-4), then the e-mail address of the user is set to the empty string.
|
||||
|
||||
## Set up OAuth2 with Okta
|
||||
|
||||
|
@ -10,7 +10,7 @@ import (
|
||||
"regexp"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
|
||||
"github.com/jmespath/go-jmespath"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@ -21,6 +21,7 @@ type SocialGenericOAuth struct {
|
||||
apiUrl string
|
||||
allowSignup bool
|
||||
emailAttributeName string
|
||||
emailAttributePath string
|
||||
teamIds []int
|
||||
}
|
||||
|
||||
@ -78,6 +79,37 @@ func (s *SocialGenericOAuth) IsOrganizationMember(client *http.Client) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// searchJSONForEmail searches the provided JSON response for an e-mail address
|
||||
// using the configured e-mail attribute path associated with the generic OAuth
|
||||
// provider.
|
||||
// Returns an empty string if an e-mail address is not found.
|
||||
func (s *SocialGenericOAuth) searchJSONForEmail(data []byte) string {
|
||||
if s.emailAttributePath == "" {
|
||||
s.log.Error("No e-mail attribute path specified")
|
||||
return ""
|
||||
}
|
||||
if len(data) == 0 {
|
||||
s.log.Error("Empty user info JSON response provided")
|
||||
return ""
|
||||
}
|
||||
var buf interface{}
|
||||
if err := json.Unmarshal(data, &buf); err != nil {
|
||||
s.log.Error("Failed to unmarshal user info JSON response", "err", err.Error())
|
||||
return ""
|
||||
}
|
||||
val, err := jmespath.Search(s.emailAttributePath, buf)
|
||||
if err != nil {
|
||||
s.log.Error("Failed to search user info JSON response with provided path", "emailAttributePath", s.emailAttributePath, "err", err.Error())
|
||||
return ""
|
||||
}
|
||||
strVal, ok := val.(string)
|
||||
if ok {
|
||||
return strVal
|
||||
}
|
||||
s.log.Error("E-mail not found when searching JSON with provided path", "emailAttributePath", s.emailAttributePath)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *SocialGenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) {
|
||||
type Record struct {
|
||||
Email string `json:"email"`
|
||||
@ -181,15 +213,16 @@ type UserInfoJson struct {
|
||||
|
||||
func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
|
||||
var data UserInfoJson
|
||||
var rawUserInfoResponse HttpGetResponse
|
||||
var err error
|
||||
|
||||
if !s.extractToken(&data, token) {
|
||||
response, err := HttpGet(client, s.apiUrl)
|
||||
rawUserInfoResponse, err = HttpGet(client, s.apiUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error getting user info: %s", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(response.Body, &data)
|
||||
err = json.Unmarshal(rawUserInfoResponse.Body, &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error decoding user info JSON: %s", err)
|
||||
}
|
||||
@ -197,7 +230,7 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token)
|
||||
|
||||
name := s.extractName(&data)
|
||||
|
||||
email := s.extractEmail(&data)
|
||||
email := s.extractEmail(&data, rawUserInfoResponse.Body)
|
||||
if email == "" {
|
||||
email, err = s.FetchPrivateEmail(client)
|
||||
if err != nil {
|
||||
@ -250,8 +283,7 @@ func (s *SocialGenericOAuth) extractToken(data *UserInfoJson, token *oauth2.Toke
|
||||
return false
|
||||
}
|
||||
|
||||
email := s.extractEmail(data)
|
||||
if email == "" {
|
||||
if email := s.extractEmail(data, payload); email == "" {
|
||||
s.log.Debug("No email found in id_token", "json", string(payload), "data", data)
|
||||
return false
|
||||
}
|
||||
@ -260,11 +292,18 @@ func (s *SocialGenericOAuth) extractToken(data *UserInfoJson, token *oauth2.Toke
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
|
||||
func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson, userInfoResp []byte) string {
|
||||
if data.Email != "" {
|
||||
return data.Email
|
||||
}
|
||||
|
||||
if s.emailAttributePath != "" {
|
||||
email := s.searchJSONForEmail(userInfoResp)
|
||||
if email != "" {
|
||||
return email
|
||||
}
|
||||
}
|
||||
|
||||
emails, ok := data.Attributes[s.emailAttributeName]
|
||||
if ok && len(emails) != 0 {
|
||||
return emails[0]
|
||||
@ -275,6 +314,7 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
|
||||
if emailErr == nil {
|
||||
return emailAddr.Address
|
||||
}
|
||||
s.log.Debug("Failed to parse e-mail address", "err", emailErr.Error())
|
||||
}
|
||||
|
||||
return ""
|
||||
|
86
pkg/login/social/generic_oauth_test.go
Normal file
86
pkg/login/social/generic_oauth_test.go
Normal file
@ -0,0 +1,86 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchJSONForEmail(t *testing.T) {
|
||||
Convey("Given a generic OAuth provider", t, func() {
|
||||
provider := SocialGenericOAuth{
|
||||
SocialBase: &SocialBase{
|
||||
log: log.New("generic_oauth_test"),
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
Name string
|
||||
UserInfoJSONResponse []byte
|
||||
EmailAttributePath string
|
||||
ExpectedResult string
|
||||
}{
|
||||
{
|
||||
Name: "Given an invalid user info JSON response",
|
||||
UserInfoJSONResponse: []byte("{"),
|
||||
EmailAttributePath: "attributes.email",
|
||||
ExpectedResult: "",
|
||||
},
|
||||
{
|
||||
Name: "Given an empty user info JSON response and empty JMES path",
|
||||
UserInfoJSONResponse: []byte{},
|
||||
EmailAttributePath: "",
|
||||
ExpectedResult: "",
|
||||
},
|
||||
{
|
||||
Name: "Given an empty user info JSON response and valid JMES path",
|
||||
UserInfoJSONResponse: []byte{},
|
||||
EmailAttributePath: "attributes.email",
|
||||
ExpectedResult: "",
|
||||
},
|
||||
{
|
||||
Name: "Given a simple user info JSON response and valid JMES path",
|
||||
UserInfoJSONResponse: []byte(`{
|
||||
"attributes": {
|
||||
"email": "grafana@localhost"
|
||||
}
|
||||
}`),
|
||||
EmailAttributePath: "attributes.email",
|
||||
ExpectedResult: "grafana@localhost",
|
||||
},
|
||||
{
|
||||
Name: "Given a user info JSON response with e-mails array and valid JMES path",
|
||||
UserInfoJSONResponse: []byte(`{
|
||||
"attributes": {
|
||||
"emails": ["grafana@localhost", "admin@localhost"]
|
||||
}
|
||||
}`),
|
||||
EmailAttributePath: "attributes.emails[0]",
|
||||
ExpectedResult: "grafana@localhost",
|
||||
},
|
||||
{
|
||||
Name: "Given a nested user info JSON response and valid JMES path",
|
||||
UserInfoJSONResponse: []byte(`{
|
||||
"identities": [
|
||||
{
|
||||
"userId": "grafana@localhost"
|
||||
},
|
||||
{
|
||||
"userId": "admin@localhost"
|
||||
}
|
||||
]
|
||||
}`),
|
||||
EmailAttributePath: "identities[0].userId",
|
||||
ExpectedResult: "grafana@localhost",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
provider.emailAttributePath = test.EmailAttributePath
|
||||
Convey(test.Name, func() {
|
||||
actualResult := provider.searchJSONForEmail(test.UserInfoJSONResponse)
|
||||
So(actualResult, ShouldEqual, test.ExpectedResult)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
@ -73,6 +73,7 @@ func NewOAuthService() {
|
||||
ApiUrl: sec.Key("api_url").String(),
|
||||
Enabled: sec.Key("enabled").MustBool(),
|
||||
EmailAttributeName: sec.Key("email_attribute_name").String(),
|
||||
EmailAttributePath: sec.Key("email_attribute_path").String(),
|
||||
AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()),
|
||||
HostedDomain: sec.Key("hosted_domain").String(),
|
||||
AllowSignup: sec.Key("allow_sign_up").MustBool(),
|
||||
@ -167,6 +168,7 @@ func NewOAuthService() {
|
||||
apiUrl: info.ApiUrl,
|
||||
allowSignup: info.AllowSignup,
|
||||
emailAttributeName: info.EmailAttributeName,
|
||||
emailAttributePath: info.EmailAttributePath,
|
||||
teamIds: sec.Key("team_ids").Ints(","),
|
||||
allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()),
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ type OAuthInfo struct {
|
||||
AuthUrl, TokenUrl string
|
||||
Enabled bool
|
||||
EmailAttributeName string
|
||||
EmailAttributePath string
|
||||
AllowedDomains []string
|
||||
HostedDomain string
|
||||
ApiUrl string
|
||||
|
Loading…
Reference in New Issue
Block a user