OAuth: Add access token as third source for user info extraction (#107636)

* Add access token as third source for user info extraction

- Add extractFromAccessToken method to extract user info from JWT access tokens
- Mutualize code by creating parseUserInfoFromJSON helper method
- Rename methods for clarity: extractFromToken -> extractFromIDToken, retrieveRawIDToken -> retrieveRawJWTPayload
- Update test suite to include comprehensive access token retrieval scenarios
- Support three sources in priority order: ID token, API response, access token
- Maintain backward compatibility while adding new functionality

* Update Generic OAuth documentation to reflect access token support

- Add access token as a third source for user information extraction
- Update configuration sections to mention access tokens alongside ID tokens and UserInfo endpoint
- Document the priority order: ID token → UserInfo endpoint → access token
- Update configuration option descriptions to reflect new functionality
- Maintain consistency with implementation changes

* Refactor access token test cases to use parameter instead of hardcoded logic

- Add AccessToken field to test case struct for explicit access token specification
- Remove hardcoded string matching logic that determined access token based on test name
- Update all access token test cases to include the AccessToken field with appropriate JWT values
- Improve test maintainability and clarity by making access tokens explicit parameters
- Remove unused strings import that was only needed for the hardcoded logic

* fix doc lint

* reduce cyclomatic complexity
This commit is contained in:
Jo
2025-07-08 15:38:11 +02:00
committed by GitHub
parent 15967cb7ab
commit 1e1fd3db38
6 changed files with 270 additions and 128 deletions
+150 -76
View File
@@ -245,78 +245,136 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client,
defer s.reloadMutex.RUnlock()
s.log.Debug("Getting user info")
toCheck := make([]*UserInfoJson, 0, 2)
if tokenData := s.extractFromToken(token); tokenData != nil {
toCheck = append(toCheck, tokenData)
// 1. Collect user info data from various sources
dataSources := s.collectUserInfoData(ctx, client, token)
// 2. Build user info from collected data
userInfo, externalOrgs, err := s.buildUserInfo(dataSources)
if err != nil {
return nil, err
}
// 3. Post-process user info
err = s.postProcessUserInfo(ctx, client, userInfo, externalOrgs)
if err != nil {
return nil, err
}
// 4. Validate user access
err = s.validateUserAccess(ctx, client, userInfo)
if err != nil {
return nil, err
}
s.log.Debug("User info result", "result", userInfo)
return userInfo, nil
}
// collectUserInfoData gathers user information from ID token, API, and access token
func (s *SocialGenericOAuth) collectUserInfoData(ctx context.Context, client *http.Client, token *oauth2.Token) []*UserInfoJson {
dataSources := make([]*UserInfoJson, 0, 3)
if idTokenData := s.extractFromIDToken(token); idTokenData != nil {
dataSources = append(dataSources, idTokenData)
}
if apiData := s.extractFromAPI(ctx, client); apiData != nil {
toCheck = append(toCheck, apiData)
dataSources = append(dataSources, apiData)
}
if accessTokenData := s.extractFromAccessToken(token); accessTokenData != nil {
dataSources = append(dataSources, accessTokenData)
}
return dataSources
}
// buildUserInfo constructs BasicUserInfo from collected data sources
func (s *SocialGenericOAuth) buildUserInfo(dataSources []*UserInfoJson) (*social.BasicUserInfo, []string, error) {
userInfo := &social.BasicUserInfo{}
var externalOrgs []string
for _, data := range toCheck {
for _, data := range dataSources {
s.log.Debug("Processing external user info", "source", data.source, "data", data)
if userInfo.Id == "" {
userInfo.Id = data.Sub
s.extractBasicUserFields(userInfo, data)
if err := s.extractRoleAndOrgs(userInfo, &externalOrgs, data); err != nil {
return nil, nil, err
}
if userInfo.Name == "" {
userInfo.Name = s.extractUserName(data)
}
s.extractUserGroups(userInfo, data)
}
if userInfo.Login == "" {
userInfo.Login = s.extractLogin(data)
}
return userInfo, externalOrgs, nil
}
if userInfo.Email == "" {
userInfo.Email = s.extractEmail(data)
if userInfo.Email != "" {
s.log.Debug("Set user info email from extracted email", "email", userInfo.Email)
}
}
// extractBasicUserFields extracts basic user fields (ID, Name, Login, Email) from data
func (s *SocialGenericOAuth) extractBasicUserFields(userInfo *social.BasicUserInfo, data *UserInfoJson) {
if userInfo.Id == "" {
userInfo.Id = data.Sub
}
if userInfo.Role == "" && !s.info.SkipOrgRoleSync {
role, grafanaAdmin, err := s.extractRoleAndAdminOptional(data.rawJSON, []string{})
if err != nil {
s.log.Warn("Failed to extract role", "err", err)
} else {
userInfo.Role = role
if s.info.AllowAssignGrafanaAdmin {
userInfo.IsGrafanaAdmin = &grafanaAdmin
}
}
}
if userInfo.Name == "" {
userInfo.Name = s.extractUserName(data)
}
if len(externalOrgs) == 0 && !s.info.SkipOrgRoleSync {
var err error
externalOrgs, err = s.extractOrgs(data.rawJSON)
if err != nil {
s.log.Warn("Failed to extract orgs", "err", err)
return nil, err
}
}
if userInfo.Login == "" {
userInfo.Login = s.extractLogin(data)
}
if len(userInfo.Groups) == 0 {
groups, err := s.extractGroups(data)
if err != nil {
s.log.Warn("Failed to extract groups", "err", err)
} else if len(groups) > 0 {
s.log.Debug("Setting user info groups from extracted groups")
userInfo.Groups = groups
if userInfo.Email == "" {
userInfo.Email = s.extractEmail(data)
if userInfo.Email != "" {
s.log.Debug("Set user info email from extracted email", "email", userInfo.Email)
}
}
}
// extractRoleAndOrgs extracts role and organization information from data
func (s *SocialGenericOAuth) extractRoleAndOrgs(userInfo *social.BasicUserInfo, externalOrgs *[]string, data *UserInfoJson) error {
if userInfo.Role == "" && !s.info.SkipOrgRoleSync {
role, grafanaAdmin, err := s.extractRoleAndAdminOptional(data.rawJSON, []string{})
if err != nil {
s.log.Warn("Failed to extract role", "err", err)
} else {
userInfo.Role = role
if s.info.AllowAssignGrafanaAdmin {
userInfo.IsGrafanaAdmin = &grafanaAdmin
}
}
}
if len(*externalOrgs) == 0 && !s.info.SkipOrgRoleSync {
orgs, err := s.extractOrgs(data.rawJSON)
if err != nil {
s.log.Warn("Failed to extract orgs", "err", err)
return err
}
*externalOrgs = orgs
}
return nil
}
// extractUserGroups extracts group information from data
func (s *SocialGenericOAuth) extractUserGroups(userInfo *social.BasicUserInfo, data *UserInfoJson) {
if len(userInfo.Groups) == 0 {
groups, err := s.extractGroups(data)
if err != nil {
s.log.Warn("Failed to extract groups", "err", err)
} else if len(groups) > 0 {
s.log.Debug("Setting user info groups from extracted groups")
userInfo.Groups = groups
}
}
}
// postProcessUserInfo handles post-processing of user info (org roles, private email, etc.)
func (s *SocialGenericOAuth) postProcessUserInfo(ctx context.Context, client *http.Client, userInfo *social.BasicUserInfo, externalOrgs []string) error {
if !s.info.SkipOrgRoleSync {
userInfo.OrgRoles = s.orgRoleMapper.MapOrgRoles(s.orgMappingCfg, externalOrgs, userInfo.Role)
if s.info.RoleAttributeStrict && len(userInfo.OrgRoles) == 0 {
// If no roles are found and role_attribute_strict is set, return an error.
// The s.info.RoleAttributeStrict is necessary, because there is a case when len(userInfo.OrgRoles) == 0,
// but strict role mapping is not enabled (when getAllOrgs fails).
return nil, errRoleAttributeStrictViolation.Errorf("could not evaluate any valid roles using IdP provided data")
return errRoleAttributeStrictViolation.Errorf("could not evaluate any valid roles using IdP provided data")
}
}
@@ -325,11 +383,11 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client,
}
if s.canFetchPrivateEmail(userInfo) {
var err error
userInfo.Email, err = s.fetchPrivateEmail(ctx, client)
email, err := s.fetchPrivateEmail(ctx, client)
if err != nil {
return nil, err
return err
}
userInfo.Email = email
s.log.Debug("Setting email from fetched private email", "email", userInfo.Email)
}
@@ -338,28 +396,32 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client,
userInfo.Login = userInfo.Email
}
return nil
}
// validateUserAccess validates user access based on team, organization, and group membership
func (s *SocialGenericOAuth) validateUserAccess(ctx context.Context, client *http.Client, userInfo *social.BasicUserInfo) error {
if !s.isTeamMember(ctx, client) {
return nil, &SocialError{"User not a member of one of the required teams"}
return &SocialError{"User not a member of one of the required teams"}
}
if !s.isOrganizationMember(ctx, client) {
return nil, &SocialError{"User not a member of one of the required organizations"}
return &SocialError{"User not a member of one of the required organizations"}
}
if !s.isGroupMember(userInfo.Groups) {
return nil, errMissingGroupMembership
return errMissingGroupMembership
}
s.log.Debug("User info result", "result", userInfo)
return userInfo, nil
return nil
}
func (s *SocialGenericOAuth) canFetchPrivateEmail(userinfo *social.BasicUserInfo) bool {
return s.info.ApiUrl != "" && userinfo.Email == ""
}
func (s *SocialGenericOAuth) extractFromToken(token *oauth2.Token) *UserInfoJson {
s.log.Debug("Extracting user info from OAuth token")
func (s *SocialGenericOAuth) extractFromIDToken(token *oauth2.Token) *UserInfoJson {
s.log.Debug("Extracting user info from OAuth ID token")
idTokenAttribute := "id_token"
if s.idTokenAttributeName != "" {
@@ -373,21 +435,44 @@ func (s *SocialGenericOAuth) extractFromToken(token *oauth2.Token) *UserInfoJson
return nil
}
rawJSON, err := s.retrieveRawIDToken(idToken)
rawJSON, err := s.retrieveRawJWTPayload(idToken)
if err != nil {
s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", token))
s.log.Warn("Error retrieving id_token payload", "error", err, "token", fmt.Sprintf("%+v", token))
return nil
}
return s.parseUserInfoFromJSON(rawJSON, "id_token")
}
func (s *SocialGenericOAuth) extractFromAccessToken(token *oauth2.Token) *UserInfoJson {
s.log.Debug("Extracting user info from OAuth access token")
accessToken := token.AccessToken
if accessToken == "" {
s.log.Debug("No access token found")
return nil
}
rawJSON, err := s.retrieveRawJWTPayload(accessToken)
if err != nil {
s.log.Warn("Error retrieving access token payload", "error", err)
return nil
}
return s.parseUserInfoFromJSON(rawJSON, "access_token")
}
// parseUserInfoFromJSON is a helper method to parse UserInfoJson from raw JSON and source
func (s *SocialGenericOAuth) parseUserInfoFromJSON(rawJSON []byte, source string) *UserInfoJson {
var data UserInfoJson
if err := json.Unmarshal(rawJSON, &data); err != nil {
s.log.Error("Error decoding id_token JSON", "raw_json", string(rawJSON), "error", err)
s.log.Error("Error decoding user info JSON", "raw_json", string(rawJSON), "error", err, "source", source)
return nil
}
data.rawJSON = rawJSON
data.source = "token"
s.log.Debug("Received id_token", "raw_json", string(data.rawJSON), "data", data.String())
data.source = source
s.log.Debug("Parsed user info from JSON", "raw_json", string(rawJSON), "data", data.String(), "source", source)
return &data
}
@@ -404,18 +489,7 @@ func (s *SocialGenericOAuth) extractFromAPI(ctx context.Context, client *http.Cl
return nil
}
rawJSON := rawUserInfoResponse.Body
var data UserInfoJson
if err := json.Unmarshal(rawJSON, &data); err != nil {
s.log.Error("Error decoding user info response", "raw_json", rawJSON, "error", err)
return nil
}
data.rawJSON = rawJSON
data.source = "API"
s.log.Debug("Received user info response from API", "raw_json", string(rawJSON), "data", data.String())
return &data
return s.parseUserInfoFromJSON(rawUserInfoResponse.Body, "API")
}
func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
@@ -31,6 +31,7 @@ func TestUserInfoSearchesForEmailAndOrgRoles(t *testing.T) {
AllowAssignGrafanaAdmin bool
ResponseBody any
OAuth2Extra any
AccessToken string
Setup func(*orgtest.FakeOrgService)
RoleAttributePath string
RoleAttributeStrict bool
@@ -440,6 +441,62 @@ func TestUserInfoSearchesForEmailAndOrgRoles(t *testing.T) {
ExpectedEmail: "john.doe@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleViewer},
},
// Access Token Test Cases
{
Name: "Given a valid access token with role, no ID token, no API response, use access token",
ResponseBody: map[string]any{},
OAuth2Extra: map[string]any{},
AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiRWRpdG9yIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20ifQ.oVEMSJVqBwrGXOcwGgXL_8J-CZhgFVPjXXSqzPJQ5JU", // { "role": "Editor", "email": "access.token@example.com" }
RoleAttributePath: "role",
ExpectedEmail: "access.token@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleEditor},
},
{
Name: "Given a valid access token with org roles, no ID token, no API response, use access token",
ResponseBody: map[string]any{},
OAuth2Extra: map[string]any{},
AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiVmlld2VyIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20iLCJpbmZvIjp7InJvbGVzIjpbImFjY2Vzcy1kZXYiLCJhY2Nlc3Mtb3BzIl19fQ.g8-mNJQDL9CJWgRTFdKBRRKbsHZfFhJrzPYQGXfxGIE", // { "role": "Viewer", "email": "access.token@example.com", "info": { "roles": [ "access-dev", "access-ops" ] }}
RoleAttributePath: "role",
OrgAttributePath: "info.roles",
OrgMapping: []string{"access-dev:org_dev:Admin", "access-ops:org_engineering:Editor"},
ExpectedEmail: "access.token@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{4: org.RoleAdmin, 5: org.RoleEditor},
},
{
Name: "Given a valid access token and ID token, prefer ID token",
ResponseBody: map[string]any{},
OAuth2Extra: map[string]any{
// { "role": "Admin", "email": "id.token@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiQWRtaW4iLCJlbWFpbCI6ImlkLnRva2VuQGV4YW1wbGUuY29tIn0.T8wcoOOPQ_av9VsOFoYJZGNFGJgG0d3LPDvtxvgODkU",
},
AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiRWRpdG9yIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20ifQ.oVEMSJVqBwrGXOcwGgXL_8J-CZhgFVPjXXSqzPJQ5JU", // { "role": "Editor", "email": "access.token@example.com" }
RoleAttributePath: "role",
ExpectedEmail: "id.token@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin},
},
{
Name: "Given a valid access token with no email, ID token with no role, API response with no data, merge",
ResponseBody: map[string]any{},
OAuth2Extra: map[string]any{
// { "email": "id.token@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImlkLnRva2VuQGV4YW1wbGUuY29tIn0.k5GwPcZvGe2BE_jgwN0ntz0nz4KlYhEd0hRRLApkTJ4",
},
AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiRWRpdG9yIn0.gfnKWZKNFNqrILhHFzabBVEWnJJIZBmQSBwLPCHhLUY", // { "role": "Editor" }
RoleAttributePath: "role",
ExpectedEmail: "id.token@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleEditor},
},
{
Name: "Given a valid access token with GrafanaAdmin role and AssignGrafanaAdmin enabled",
AllowAssignGrafanaAdmin: true,
ResponseBody: map[string]any{},
OAuth2Extra: map[string]any{},
AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiR3JhZmFuYUFkbWluIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20ifQ.fJPjMgZW9bOYXOLgOUekNQmNrVbUNhU1iqQJwqFWzUY", // { "role": "GrafanaAdmin", "email": "access.token@example.com" }
RoleAttributePath: "role",
ExpectedEmail: "access.token@example.com",
ExpectedGrafanaAdmin: trueBoolPtr(),
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin},
},
}
cfg := &setting.Cfg{
@@ -479,8 +536,9 @@ func TestUserInfoSearchesForEmailAndOrgRoles(t *testing.T) {
require.NoError(t, err)
}))
provider.info.ApiUrl = ts.URL
staticToken := oauth2.Token{
AccessToken: "",
AccessToken: tc.AccessToken,
TokenType: "",
RefreshToken: "",
Expiry: time.Now(),
@@ -853,7 +911,7 @@ func TestPayloadCompression(t *testing.T) {
}
token := staticToken.WithExtra(test.OAuth2Extra)
userInfo := provider.extractFromToken(token)
userInfo := provider.extractFromIDToken(token)
if test.ExpectedEmail == "" {
require.Nil(t, userInfo, "Testing case %q", test.Name)
+1 -1
View File
@@ -275,7 +275,7 @@ func (s *SocialGitlab) extractFromToken(ctx context.Context, client *http.Client
return nil, nil
}
rawJSON, err := s.retrieveRawIDToken(idToken)
rawJSON, err := s.retrieveRawJWTPayload(idToken)
if err != nil {
s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", idToken))
return nil, nil
+1 -1
View File
@@ -236,7 +236,7 @@ func (s *SocialGoogle) extractFromToken(_ context.Context, _ *http.Client, token
return nil, nil
}
rawJSON, err := s.retrieveRawIDToken(idToken)
rawJSON, err := s.retrieveRawJWTPayload(idToken)
if err != nil {
s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", idToken))
return nil, nil
+5 -5
View File
@@ -196,21 +196,21 @@ func (s *SocialBase) isGroupMember(groups []string) bool {
return false
}
func (s *SocialBase) retrieveRawIDToken(idToken any) ([]byte, error) {
tokenString, ok := idToken.(string)
func (s *SocialBase) retrieveRawJWTPayload(token any) ([]byte, error) {
tokenString, ok := token.(string)
if !ok {
return nil, fmt.Errorf("id_token is not a string: %v", idToken)
return nil, fmt.Errorf("token is not a string: %v", token)
}
jwtRegexp := regexp.MustCompile("^([-_a-zA-Z0-9=]+)[.]([-_a-zA-Z0-9=]+)[.]([-_a-zA-Z0-9=]+)$")
matched := jwtRegexp.FindStringSubmatch(tokenString)
if matched == nil {
return nil, fmt.Errorf("id_token is not in JWT format: %s", tokenString)
return nil, fmt.Errorf("token is not in JWT format: %s", tokenString)
}
rawJSON, err := base64.RawURLEncoding.DecodeString(matched[2])
if err != nil {
return nil, fmt.Errorf("error base64 decoding id_token: %w", err)
return nil, fmt.Errorf("error base64 decoding token payload: %w", err)
}
headerBytes, err := base64.RawURLEncoding.DecodeString(matched[1])