OAuth: Fix role mapping from id token (#20300)

As part of the new improvements to JMESPath in #17149 a 
commit ensuring Role and Email data could be queried from 
id_token was lost.
This refactors and fixes extracting from both token and user 
info api consistently where before only either token or 
either user info api was used for extracting data/attributes.

Fixes #20243

Co-authored-by: Timo Wendt <timo@tjwendt.de>
Co-authored-by: twendt <timo@tjwendt.de>
Co-authored-by: henninge <henning@eggers.name>
Co-Authored-by: Henning Eggers <henning.eggers@inovex.de>
Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
This commit is contained in:
Sean Johnson 2020-01-18 04:57:24 +11:00 committed by Marcus Efraimsson
parent fef31acbf2
commit 7cbf3d8dab
2 changed files with 394 additions and 178 deletions

View File

@ -9,6 +9,8 @@ import (
"net/mail"
"regexp"
"github.com/grafana/grafana/pkg/util/errutil"
"github.com/grafana/grafana/pkg/models"
"github.com/jmespath/go-jmespath"
"golang.org/x/oauth2"
@ -43,8 +45,8 @@ func (s *SocialGenericOAuth) IsTeamMember(client *http.Client) bool {
return true
}
teamMemberships, err := s.FetchTeamMemberships(client)
if err != nil {
teamMemberships, ok := s.FetchTeamMemberships(client)
if !ok {
return false
}
@ -64,8 +66,8 @@ func (s *SocialGenericOAuth) IsOrganizationMember(client *http.Client) bool {
return true
}
organizations, err := s.FetchOrganizations(client)
if err != nil {
organizations, ok := s.FetchOrganizations(client)
if !ok {
return false
}
@ -80,6 +82,189 @@ func (s *SocialGenericOAuth) IsOrganizationMember(client *http.Client) bool {
return false
}
type UserInfoJson struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Login string `json:"login"`
Username string `json:"username"`
Email string `json:"email"`
Upn string `json:"upn"`
Attributes map[string][]string `json:"attributes"`
rawJSON []byte
}
func (info *UserInfoJson) String() string {
return fmt.Sprintf(
"Name: %s, Displayname: %s, Login: %s, Username: %s, Email: %s, Upn: %s, Attributes: %v",
info.Name, info.DisplayName, info.Login, info.Username, info.Email, info.Upn, info.Attributes)
}
func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
var data UserInfoJson
var err error
userInfo := &BasicUserInfo{}
if s.extractToken(&data, token) {
s.fillUserInfo(userInfo, &data)
}
if s.extractAPI(&data, client) {
s.fillUserInfo(userInfo, &data)
}
if userInfo.Email == "" {
userInfo.Email, err = s.FetchPrivateEmail(client)
if err != nil {
return nil, err
}
}
if userInfo.Login == "" {
userInfo.Login = userInfo.Email
}
if !s.IsTeamMember(client) {
return nil, errors.New("User not a member of one of the required teams")
}
if !s.IsOrganizationMember(client) {
return nil, errors.New("User not a member of one of the required organizations")
}
s.log.Debug("User info result", "result", userInfo)
return userInfo, nil
}
func (s *SocialGenericOAuth) fillUserInfo(userInfo *BasicUserInfo, data *UserInfoJson) {
if userInfo.Email == "" {
userInfo.Email = s.extractEmail(data)
}
if userInfo.Role == "" {
userInfo.Role = s.extractRole(data)
}
if userInfo.Name == "" {
userInfo.Name = s.extractName(data)
}
if userInfo.Login == "" {
userInfo.Login = s.extractLogin(data)
}
}
func (s *SocialGenericOAuth) extractToken(data *UserInfoJson, token *oauth2.Token) bool {
var err error
idToken := token.Extra("id_token")
if idToken == nil {
s.log.Debug("No id_token found", "token", token)
return false
}
jwtRegexp := regexp.MustCompile("^([-_a-zA-Z0-9=]+)[.]([-_a-zA-Z0-9=]+)[.]([-_a-zA-Z0-9=]+)$")
matched := jwtRegexp.FindStringSubmatch(idToken.(string))
if matched == nil {
s.log.Debug("id_token is not in JWT format", "id_token", idToken.(string))
return false
}
data.rawJSON, err = base64.RawURLEncoding.DecodeString(matched[2])
if err != nil {
s.log.Error("Error base64 decoding id_token", "raw_payload", matched[2], "error", err)
return false
}
err = json.Unmarshal(data.rawJSON, data)
if err != nil {
s.log.Error("Error decoding id_token JSON", "raw_json", string(data.rawJSON), "error", err)
data.rawJSON = []byte{}
return false
}
s.log.Debug("Received id_token", "raw_json", string(data.rawJSON), "data", data)
return true
}
func (s *SocialGenericOAuth) extractAPI(data *UserInfoJson, client *http.Client) bool {
rawUserInfoResponse, err := HttpGet(client, s.apiUrl)
if err != nil {
s.log.Debug("Error getting user info response", "url", s.apiUrl, "error", err)
return false
}
data.rawJSON = rawUserInfoResponse.Body
err = json.Unmarshal(data.rawJSON, data)
if err != nil {
s.log.Error("Error decoding user info response", "raw_json", data.rawJSON, "error", err)
data.rawJSON = []byte{}
return false
}
s.log.Debug("Received user info response", "raw_json", string(data.rawJSON), "data", data)
return true
}
func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
if data.Email != "" {
return data.Email
}
if s.emailAttributePath != "" {
email := s.searchJSONForAttr(s.emailAttributePath, data.rawJSON)
if email != "" {
return email
}
}
emails, ok := data.Attributes[s.emailAttributeName]
if ok && len(emails) != 0 {
return emails[0]
}
if data.Upn != "" {
emailAddr, emailErr := mail.ParseAddress(data.Upn)
if emailErr == nil {
return emailAddr.Address
}
s.log.Debug("Failed to parse e-mail address", "error", emailErr.Error())
}
return ""
}
func (s *SocialGenericOAuth) extractRole(data *UserInfoJson) string {
if s.roleAttributePath != "" {
role := s.searchJSONForAttr(s.roleAttributePath, data.rawJSON)
if role != "" {
return role
}
}
return ""
}
func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson) string {
if data.Login != "" {
return data.Login
}
if data.Username != "" {
return data.Username
}
return ""
}
func (s *SocialGenericOAuth) extractName(data *UserInfoJson) string {
if data.Name != "" {
return data.Name
}
if data.DisplayName != "" {
return data.DisplayName
}
return ""
}
// searchJSONForAttr searches the provided JSON response for the given attribute
// using the configured attribute path associated with the generic OAuth
// provider.
@ -122,7 +307,8 @@ func (s *SocialGenericOAuth) FetchPrivateEmail(client *http.Client) (string, err
response, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/emails"))
if err != nil {
return "", fmt.Errorf("Error getting email address: %s", err)
s.log.Error("Error getting email address", "url", s.apiUrl+"/emails", "error", err)
return "", errutil.Wrap("Error getting email address", err)
}
var records []Record
@ -135,12 +321,15 @@ func (s *SocialGenericOAuth) FetchPrivateEmail(client *http.Client) (string, err
err = json.Unmarshal(response.Body, &data)
if err != nil {
return "", fmt.Errorf("Error getting email address: %s", err)
s.log.Error("Error decoding email addresses response", "raw_json", string(response.Body), "error", err)
return "", errutil.Wrap("Erro decoding email addresses response", err)
}
records = data.Values
}
s.log.Debug("Received email addresses", "emails", records)
var email = ""
for _, record := range records {
if record.Primary || record.IsPrimary {
@ -149,24 +338,28 @@ func (s *SocialGenericOAuth) FetchPrivateEmail(client *http.Client) (string, err
}
}
s.log.Debug("Using email address", "email", email)
return email, nil
}
func (s *SocialGenericOAuth) FetchTeamMemberships(client *http.Client) ([]int, error) {
func (s *SocialGenericOAuth) FetchTeamMemberships(client *http.Client) ([]int, bool) {
type Record struct {
Id int `json:"id"`
}
response, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/teams"))
if err != nil {
return nil, fmt.Errorf("Error getting team memberships: %s", err)
s.log.Error("Error getting team memberships", "url", s.apiUrl+"/teams", "error", err)
return nil, false
}
var records []Record
err = json.Unmarshal(response.Body, &records)
if err != nil {
return nil, fmt.Errorf("Error getting team memberships: %s", err)
s.log.Error("Error decoding team memberships response", "raw_json", string(response.Body), "error", err)
return nil, false
}
var ids = make([]int, len(records))
@ -174,24 +367,28 @@ func (s *SocialGenericOAuth) FetchTeamMemberships(client *http.Client) ([]int, e
ids[i] = record.Id
}
return ids, nil
s.log.Debug("Received team memberships", "ids", ids)
return ids, true
}
func (s *SocialGenericOAuth) FetchOrganizations(client *http.Client) ([]string, error) {
func (s *SocialGenericOAuth) FetchOrganizations(client *http.Client) ([]string, bool) {
type Record struct {
Login string `json:"login"`
}
response, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/orgs"))
if err != nil {
return nil, fmt.Errorf("Error getting organizations: %s", err)
s.log.Error("Error getting organizations", "url", s.apiUrl+"/orgs", "error", err)
return nil, false
}
var records []Record
err = json.Unmarshal(response.Body, &records)
if err != nil {
return nil, fmt.Errorf("Error getting organizations: %s", err)
s.log.Error("Error decoding organization response", "response", string(response.Body), "error", err)
return nil, false
}
var logins = make([]string, len(records))
@ -199,161 +396,7 @@ func (s *SocialGenericOAuth) FetchOrganizations(client *http.Client) ([]string,
logins[i] = record.Login
}
return logins, nil
}
type UserInfoJson struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Login string `json:"login"`
Username string `json:"username"`
Email string `json:"email"`
Upn string `json:"upn"`
Attributes map[string][]string `json:"attributes"`
}
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) {
rawUserInfoResponse, err = HttpGet(client, s.apiUrl)
if err != nil {
return nil, fmt.Errorf("Error getting user info: %s", err)
}
err = json.Unmarshal(rawUserInfoResponse.Body, &data)
if err != nil {
return nil, fmt.Errorf("Error decoding user info JSON: %s", err)
}
}
name := s.extractName(&data)
email := s.extractEmail(&data, rawUserInfoResponse.Body)
if email == "" {
email, err = s.FetchPrivateEmail(client)
if err != nil {
return nil, err
}
}
role := s.extractRole(&data, rawUserInfoResponse.Body)
login := s.extractLogin(&data, email)
userInfo := &BasicUserInfo{
Name: name,
Login: login,
Email: email,
Role: role,
}
if !s.IsTeamMember(client) {
return nil, errors.New("User not a member of one of the required teams")
}
if !s.IsOrganizationMember(client) {
return nil, errors.New("User not a member of one of the required organizations")
}
return userInfo, nil
}
func (s *SocialGenericOAuth) extractToken(data *UserInfoJson, token *oauth2.Token) bool {
idToken := token.Extra("id_token")
if idToken == nil {
s.log.Debug("No id_token found", "token", token)
return false
}
jwtRegexp := regexp.MustCompile("^([-_a-zA-Z0-9=]+)[.]([-_a-zA-Z0-9=]+)[.]([-_a-zA-Z0-9=]+)$")
matched := jwtRegexp.FindStringSubmatch(idToken.(string))
if matched == nil {
s.log.Debug("id_token is not in JWT format", "id_token", idToken.(string))
return false
}
payload, err := base64.RawURLEncoding.DecodeString(matched[2])
if err != nil {
s.log.Error("Error base64 decoding id_token", "raw_payload", matched[2], "err", err)
return false
}
err = json.Unmarshal(payload, data)
if err != nil {
s.log.Error("Error decoding id_token JSON", "payload", string(payload), "err", err)
return false
}
if email := s.extractEmail(data, payload); email == "" {
s.log.Debug("No email found in id_token", "json", string(payload), "data", data)
return false
}
s.log.Debug("Received id_token", "json", string(payload), "data", data)
return true
}
func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson, userInfoResp []byte) string {
if data.Email != "" {
return data.Email
}
if s.emailAttributePath != "" {
email := s.searchJSONForAttr(s.emailAttributePath, userInfoResp)
if email != "" {
return email
}
}
emails, ok := data.Attributes[s.emailAttributeName]
if ok && len(emails) != 0 {
return emails[0]
}
if data.Upn != "" {
emailAddr, emailErr := mail.ParseAddress(data.Upn)
if emailErr == nil {
return emailAddr.Address
}
s.log.Debug("Failed to parse e-mail address", "err", emailErr.Error())
}
return ""
}
func (s *SocialGenericOAuth) extractRole(data *UserInfoJson, userInfoResp []byte) string {
if s.roleAttributePath != "" {
role := s.searchJSONForAttr(s.roleAttributePath, userInfoResp)
if role != "" {
return role
}
}
return ""
}
func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson, email string) string {
if data.Login != "" {
return data.Login
}
if data.Username != "" {
return data.Username
}
return email
}
func (s *SocialGenericOAuth) extractName(data *UserInfoJson) string {
if data.Name != "" {
return data.Name
}
if data.DisplayName != "" {
return data.DisplayName
}
return ""
s.log.Debug("Received organizations", "logins", logins)
return logins, true
}

View File

@ -1,13 +1,22 @@
package social
import (
"github.com/grafana/grafana/pkg/infra/log"
. "github.com/smartystreets/goconvey/convey"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"time"
"github.com/stretchr/testify/require"
"testing"
"github.com/grafana/grafana/pkg/infra/log"
"golang.org/x/oauth2"
)
func TestSearchJSONForEmail(t *testing.T) {
Convey("Given a generic OAuth provider", t, func() {
t.Run("Given a generic OAuth provider", func(t *testing.T) {
provider := SocialGenericOAuth{
SocialBase: &SocialBase{
log: log.New("generic_oauth_test"),
@ -77,16 +86,16 @@ func TestSearchJSONForEmail(t *testing.T) {
for _, test := range tests {
provider.emailAttributePath = test.EmailAttributePath
Convey(test.Name, func() {
t.Run(test.Name, func(t *testing.T) {
actualResult := provider.searchJSONForAttr(test.EmailAttributePath, test.UserInfoJSONResponse)
So(actualResult, ShouldEqual, test.ExpectedResult)
require.Equal(t, test.ExpectedResult, actualResult)
})
}
})
}
func TestSearchJSONForRole(t *testing.T) {
Convey("Given a generic OAuth provider", t, func() {
t.Run("Given a generic OAuth provider", func(t *testing.T) {
provider := SocialGenericOAuth{
SocialBase: &SocialBase{
log: log.New("generic_oauth_test"),
@ -131,9 +140,173 @@ func TestSearchJSONForRole(t *testing.T) {
for _, test := range tests {
provider.roleAttributePath = test.RoleAttributePath
Convey(test.Name, func() {
t.Run(test.Name, func(t *testing.T) {
actualResult := provider.searchJSONForAttr(test.RoleAttributePath, test.UserInfoJSONResponse)
So(actualResult, ShouldEqual, test.ExpectedResult)
require.Equal(t, test.ExpectedResult, actualResult)
})
}
})
}
func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
t.Run("Given a generic OAuth provider", func(t *testing.T) {
provider := SocialGenericOAuth{
SocialBase: &SocialBase{
log: log.New("generic_oauth_test"),
},
emailAttributePath: "email",
}
tests := []struct {
Name string
APIURLReponse interface{}
OAuth2Extra interface{}
RoleAttributePath string
ExpectedEmail string
ExpectedRole string
}{
{
Name: "Given a valid id_token, a valid role path, no api response, use id_token",
OAuth2Extra: map[string]interface{}{
// { "role": "Admin", "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiQWRtaW4iLCJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.9PtHcCaXxZa2HDlASyKIaFGfOKlw2ILQo32xlvhvhRg",
},
RoleAttributePath: "role",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Admin",
},
{
Name: "Given a valid id_token, no role path, no api response, use id_token",
OAuth2Extra: map[string]interface{}{
// { "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.k5GwPcZvGe2BE_jgwN0ntz0nz4KlYhEd0hRRLApkTJ4",
},
RoleAttributePath: "",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "",
},
{
Name: "Given a valid id_token, an invalid role path, no api response, use id_token",
OAuth2Extra: map[string]interface{}{
// { "role": "Admin", "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiQWRtaW4iLCJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.9PtHcCaXxZa2HDlASyKIaFGfOKlw2ILQo32xlvhvhRg",
},
RoleAttributePath: "invalid_path",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "",
},
{
Name: "Given no id_token, a valid role path, a valid api response, use api response",
APIURLReponse: map[string]interface{}{
"role": "Admin",
"email": "john.doe@example.com",
},
RoleAttributePath: "role",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Admin",
},
{
Name: "Given no id_token, no role path, a valid api response, use api response",
APIURLReponse: map[string]interface{}{
"email": "john.doe@example.com",
},
RoleAttributePath: "",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "",
},
{
Name: "Given no id_token, a role path, a valid api response without a role, use api response",
APIURLReponse: map[string]interface{}{
"email": "john.doe@example.com",
},
RoleAttributePath: "role",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "",
},
{
Name: "Given no id_token, a valid role path, no api response, no data",
RoleAttributePath: "role",
ExpectedEmail: "",
ExpectedRole: "",
},
{
Name: "Given a valid id_token, a valid role path, a valid api response, prefer id_token",
OAuth2Extra: map[string]interface{}{
// { "role": "Admin", "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiQWRtaW4iLCJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.9PtHcCaXxZa2HDlASyKIaFGfOKlw2ILQo32xlvhvhRg",
},
APIURLReponse: map[string]interface{}{
"role": "FromResponse",
"email": "from_response@example.com",
},
RoleAttributePath: "role",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Admin",
},
{
Name: "Given a valid id_token, an invalid role path, a valid api response, prefer id_token",
OAuth2Extra: map[string]interface{}{
// { "role": "Admin", "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiQWRtaW4iLCJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.9PtHcCaXxZa2HDlASyKIaFGfOKlw2ILQo32xlvhvhRg",
},
APIURLReponse: map[string]interface{}{
"role": "FromResponse",
"email": "from_response@example.com",
},
RoleAttributePath: "invalid_path",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "",
},
{
Name: "Given a valid id_token with no email, a valid role path, a valid api response with no role, merge",
OAuth2Extra: map[string]interface{}{
// { "role": "Admin" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiQWRtaW4ifQ.k5GwPcZvGe2BE_jgwN0ntz0nz4KlYhEd0hRRLApkTJ4",
},
APIURLReponse: map[string]interface{}{
"email": "from_response@example.com",
},
RoleAttributePath: "role",
ExpectedEmail: "from_response@example.com",
ExpectedRole: "Admin",
},
{
Name: "Given a valid id_token with no role, a valid role path, a valid api response with no email, merge",
OAuth2Extra: map[string]interface{}{
// { "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.k5GwPcZvGe2BE_jgwN0ntz0nz4KlYhEd0hRRLApkTJ4",
},
APIURLReponse: map[string]interface{}{
"role": "FromResponse",
},
RoleAttributePath: "role",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "FromResponse",
},
}
for _, test := range tests {
provider.roleAttributePath = test.RoleAttributePath
t.Run(test.Name, func(t *testing.T) {
response, _ := json.Marshal(test.APIURLReponse)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, string(response))
}))
provider.apiUrl = ts.URL
staticToken := oauth2.Token{
AccessToken: "",
TokenType: "",
RefreshToken: "",
Expiry: time.Now(),
}
token := staticToken.WithExtra(test.OAuth2Extra)
actualResult, _ := provider.UserInfo(ts.Client(), token)
require.Equal(t, test.ExpectedEmail, actualResult.Email)
require.Equal(t, test.ExpectedEmail, actualResult.Login)
require.Equal(t, test.ExpectedRole, actualResult.Role)
})
}
})