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

View File

@ -134,7 +134,7 @@ To integrate your OAuth2 provider with Grafana using our Generic OAuth authentic
### Configure login
Grafana can resolve a user's login from the OAuth2 ID token or user information retrieved from the OAuth2 UserInfo endpoint.
Grafana can resolve a user's login from the OAuth2 ID token, user information retrieved from the OAuth2 UserInfo endpoint, or the OAuth2 access token.
Grafana looks at these sources in the order listed until it finds a login.
If no login is found, then the user's login is set to user's email address.
@ -146,10 +146,12 @@ Refer to the following table for information on what to configure based on how y
| Another field of the OAuth2 ID token. | Set `login_attribute_path` configuration option. |
| `login` or `username` field of the user information from the UserInfo endpoint. | N/A |
| Another field of the user information from the UserInfo endpoint. | Set `login_attribute_path` configuration option. |
| `login` or `username` field of the OAuth2 access token. | N/A |
| Another field of the OAuth2 access token. | Set `login_attribute_path` configuration option. |
### Configure display name
Grafana can resolve a user's display name from the OAuth2 ID token or user information retrieved from the OAuth2 UserInfo endpoint.
Grafana can resolve a user's display name from the OAuth2 ID token, user information retrieved from the OAuth2 UserInfo endpoint, or the OAuth2 access token.
Grafana looks at these sources in the order listed until it finds a display name.
If no display name is found, then user's login is displayed instead.
@ -161,10 +163,12 @@ Refer to the following table for information on what you need to configure depen
| Another field of the OAuth2 ID token. | Set `name_attribute_path` configuration option. |
| `name` or `display_name` field of the user information from the UserInfo endpoint. | N/A |
| Another field of the user information from the UserInfo endpoint. | Set `name_attribute_path` configuration option. |
| `name` or `display_name` field of the OAuth2 access token. | N/A |
| Another field of the OAuth2 access token. | Set `name_attribute_path` configuration option. |
### Configure email address
Grafana can resolve the user's email address from the OAuth2 ID token, the user information retrieved from the OAuth2 UserInfo endpoint, or the OAuth2 `/emails` endpoint.
Grafana can resolve the user's email address from the OAuth2 ID token, the user information retrieved from the OAuth2 UserInfo endpoint, the OAuth2 access token, or the OAuth2 `/emails` endpoint.
Grafana looks at these sources in the order listed until an email address is found.
If no email is found, then the email address of the user is set to an empty string.
@ -177,6 +181,10 @@ Refer to the following table for information on what to configure based on how t
| `upn` field of the OAuth2 ID token. | N/A |
| `email` field of the user information from the UserInfo endpoint. | N/A |
| Another field of the user information from the UserInfo endpoint. | Set `email_attribute_path` configuration option. |
| `email` field of the OAuth2 access token. | N/A |
| `attributes` map of the OAuth2 access token. | Set `email_attribute_name` configuration option. By default, Grafana searches for email under `email:primary` key. |
| `upn` field of the OAuth2 access token. | N/A |
| Another field of the OAuth2 access token. | Set `email_attribute_path` configuration option. |
| Email address marked as primary from the `/emails` endpoint of <br /> the OAuth2 provider (obtained by appending `/emails` to the URL <br /> configured with `api_url`) | N/A |
### Configure a refresh token
@ -199,6 +207,7 @@ The `accessTokenExpirationCheck` feature toggle has been removed in Grafana v10.
Unless `skip_org_role_sync` option is enabled, the user's role will be set to the role retrieved from the auth provider upon user login.
The user's role is retrieved using a [JMESPath](http://jmespath.org/examples.html) expression from the `role_attribute_path` configuration option.
Grafana will first evaluate the expression using the OAuth2 ID token. If no role is found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. If still no role is found, the expression will be evaluated using the OAuth2 access token.
To map the server administrator role, use the `allow_assign_grafana_admin` configuration option.
Refer to [configuration options](#configuration-options) for more information.
@ -326,6 +335,7 @@ By using Team Sync, you can link your OAuth2 groups to teams within Grafana. Thi
Teams for each user are synchronized when the user logs in.
Generic OAuth groups can be referenced by group ID, such as `8bab1c86-8fba-33e5-2089-1d1c80ec267d` or `myteam`.
Group information can be extracted from the OAuth2 ID token, user information from the UserInfo endpoint, or the OAuth2 access token.
For information on configuring OAuth2 groups with Grafana using the `groups_attribute_path` configuration option, refer to [configuration options](#configuration-options).
To learn more about Team Sync, refer to [Configure team sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-team-sync/).
@ -359,46 +369,46 @@ The following table outlines the various Generic OAuth configuration options. Yo
If the configuration option requires a JMESPath expression that includes a colon, enclose the entire expression in quotes to prevent parsing errors. For example `role_attribute_path: "role:view"`
{{< /admonition >}}
| Setting | Required | Supported on Cloud | Description | Default |
| ---------------------------- | -------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| `enabled` | No | Yes | Enables Generic OAuth authentication. | `false` |
| `name` | No | Yes | Name that refers to the Generic OAuth authentication from the Grafana user interface. | `OAuth` |
| `icon` | No | Yes | Icon used for the Generic OAuth authentication in the Grafana user interface. | `signin` |
| `client_id` | Yes | Yes | Client ID provided by your OAuth2 app. | |
| `client_secret` | Yes | Yes | Client secret provided by your OAuth2 app. | |
| `auth_url` | Yes | Yes | Authorization endpoint of your OAuth2 provider. | |
| `token_url` | Yes | Yes | Endpoint used to obtain the OAuth2 access token. | |
| `api_url` | Yes | Yes | Endpoint used to obtain user information compatible with [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo). | |
| `auth_style` | No | Yes | Name of the [OAuth2 AuthStyle](https://pkg.go.dev/golang.org/x/oauth2#AuthStyle) to be used when ID token is requested from OAuth2 provider. It determines how `client_id` and `client_secret` are sent to Oauth2 provider. Available values are `AutoDetect`, `InParams` and `InHeader`. | `AutoDetect` |
| `scopes` | No | Yes | List of comma- or space-separated OAuth2 scopes. | `user:email` |
| `empty_scopes` | No | Yes | Set to `true` to use an empty scope during authentication. | `false` |
| `allow_sign_up` | No | Yes | Controls Grafana user creation through the Generic OAuth login. Only existing Grafana users can log in with Generic OAuth if set to `false`. | `true` |
| `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` |
| `id_token_attribute_name` | No | Yes | The name of the key used to extract the ID token from the returned OAuth2 token. | `id_token` |
| `login_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user login lookup from the user ID token. For more information on how user login is retrieved, refer to [Configure login](#configure-login). | |
| `name_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user name lookup from the user ID token. This name will be used as the user's display name. For more information on how user display name is retrieved, refer to [Configure display name](#configure-display-name). | |
| `email_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user email lookup from the user information. For more information on how user email is retrieved, refer to [Configure email address](#configure-email-address). | |
| `email_attribute_name` | No | Yes | Name of the key to use for user email lookup within the `attributes` map of OAuth2 ID token. For more information on how user email is retrieved, refer to [Configure email address](#configure-email-address). | `email:primary` |
| `role_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no role is found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a valid Grafana role (`None`, `Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | |
| `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` |
| `skip_org_role_sync` | No | Yes | Set to `true` to stop automatically syncing user roles. This will allow you to set organization roles for your users from within Grafana manually. | `false` |
| `org_attribute_path` | No | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana org to role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no value is returned, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation will be mapped to org roles based on `org_mapping`. For more information on org to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | |
| `org_mapping` | No | No | List of comma- or space-separated `<ExternalOrgName>:<OrgIdOrName>:<Role>` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | |
| `allow_assign_grafana_admin` | No | No | Set to `true` to enable automatic sync of the Grafana server administrator role. If this option is set to `true` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user the server administrator privileges and organization administrator role. If this option is set to `false` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user only organization administrator role. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` |
| `groups_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user group lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no groups are found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a string array of groups. | |
| `allowed_groups` | No | Yes | List of comma- or space-separated groups. The user should be a member of at least one group to log in. If you configure `allowed_groups`, you must also configure `groups_attribute_path`. | |
| `allowed_organizations` | No | Yes | List of comma- or space-separated organizations. The user should be a member of at least one organization to log in. | |
| `allowed_domains` | No | Yes | List of comma- or space-separated domains. The user should belong to at least one domain to log in. | |
| `team_ids` | No | Yes | String list of team IDs. If set, the user must be a member of one of the given teams to log in. If you configure `team_ids`, you must also configure `teams_url` and `team_ids_attribute_path`. | |
| `team_ids_attribute_path` | No | Yes | The [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana team ID lookup within the results returned by the `teams_url` endpoint. | |
| `teams_url` | No | Yes | The URL used to query for team IDs. If not set, the default value is `/teams`. If you configure `teams_url`, you must also configure `team_ids_attribute_path`. | |
| `tls_skip_verify_insecure` | No | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` |
| `tls_client_cert` | No | No | The path to the certificate. | |
| `tls_client_key` | No | No | The path to the key. | |
| `tls_client_ca` | No | No | The path to the trusted certificate authority list. | |
| `use_pkce` | No | Yes | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `false` |
| `use_refresh_token` | No | Yes | Set to `true` to use refresh token and check access token expiration. | `false` |
| `signout_redirect_url` | No | Yes | URL to redirect to after the user logs out. | |
| Setting | Required | Supported on Cloud | Description | Default |
| ---------------------------- | -------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| `enabled` | No | Yes | Enables Generic OAuth authentication. | `false` |
| `name` | No | Yes | Name that refers to the Generic OAuth authentication from the Grafana user interface. | `OAuth` |
| `icon` | No | Yes | Icon used for the Generic OAuth authentication in the Grafana user interface. | `signin` |
| `client_id` | Yes | Yes | Client ID provided by your OAuth2 app. | |
| `client_secret` | Yes | Yes | Client secret provided by your OAuth2 app. | |
| `auth_url` | Yes | Yes | Authorization endpoint of your OAuth2 provider. | |
| `token_url` | Yes | Yes | Endpoint used to obtain the OAuth2 access token. | |
| `api_url` | Yes | Yes | Endpoint used to obtain user information compatible with [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo). | |
| `auth_style` | No | Yes | Name of the [OAuth2 AuthStyle](https://pkg.go.dev/golang.org/x/oauth2#AuthStyle) to be used when ID token is requested from OAuth2 provider. It determines how `client_id` and `client_secret` are sent to Oauth2 provider. Available values are `AutoDetect`, `InParams` and `InHeader`. | `AutoDetect` |
| `scopes` | No | Yes | List of comma- or space-separated OAuth2 scopes. | `user:email` |
| `empty_scopes` | No | Yes | Set to `true` to use an empty scope during authentication. | `false` |
| `allow_sign_up` | No | Yes | Controls Grafana user creation through the Generic OAuth login. Only existing Grafana users can log in with Generic OAuth if set to `false`. | `true` |
| `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` |
| `id_token_attribute_name` | No | Yes | The name of the key used to extract the ID token from the returned OAuth2 token. | `id_token` |
| `login_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user login lookup from the user ID token. For more information on how user login is retrieved, refer to [Configure login](#configure-login). | |
| `name_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user name lookup from the user ID token. This name will be used as the user's display name. For more information on how user display name is retrieved, refer to [Configure display name](#configure-display-name). | |
| `email_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user email lookup from the user information. For more information on how user email is retrieved, refer to [Configure email address](#configure-email-address). | |
| `email_attribute_name` | No | Yes | Name of the key to use for user email lookup within the `attributes` map of OAuth2 ID token. For more information on how user email is retrieved, refer to [Configure email address](#configure-email-address). | `email:primary` |
| `role_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no role is found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. If still no role is found, the expression will be evaluated using the OAuth2 access token. The result of the evaluation should be a valid Grafana role (`None`, `Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | |
| `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` |
| `skip_org_role_sync` | No | Yes | Set to `true` to stop automatically syncing user roles. This will allow you to set organization roles for your users from within Grafana manually. | `false` |
| `org_attribute_path` | No | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana org to role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no value is returned, the expression will be evaluated using the user information obtained from the UserInfo endpoint. If still no value is returned, the expression will be evaluated using the OAuth2 access token. The result of the evaluation will be mapped to org roles based on `org_mapping`. For more information on org to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | |
| `org_mapping` | No | No | List of comma- or space-separated `<ExternalOrgName>:<OrgIdOrName>:<Role>` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | |
| `allow_assign_grafana_admin` | No | No | Set to `true` to enable automatic sync of the Grafana server administrator role. If this option is set to `true` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user the server administrator privileges and organization administrator role. If this option is set to `false` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user only organization administrator role. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` |
| `groups_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user group lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no groups are found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. If still no groups are found, the expression will be evaluated using the OAuth2 access token. The result of the evaluation should be a string array of groups. | |
| `allowed_groups` | No | Yes | List of comma- or space-separated groups. The user should be a member of at least one group to log in. If you configure `allowed_groups`, you must also configure `groups_attribute_path`. | |
| `allowed_organizations` | No | Yes | List of comma- or space-separated organizations. The user should be a member of at least one organization to log in. | |
| `allowed_domains` | No | Yes | List of comma- or space-separated domains. The user should belong to at least one domain to log in. | |
| `team_ids` | No | Yes | String list of team IDs. If set, the user must be a member of one of the given teams to log in. If you configure `team_ids`, you must also configure `teams_url` and `team_ids_attribute_path`. | |
| `team_ids_attribute_path` | No | Yes | The [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana team ID lookup within the results returned by the `teams_url` endpoint. | |
| `teams_url` | No | Yes | The URL used to query for team IDs. If not set, the default value is `/teams`. If you configure `teams_url`, you must also configure `team_ids_attribute_path`. | |
| `tls_skip_verify_insecure` | No | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` |
| `tls_client_cert` | No | No | The path to the certificate. | |
| `tls_client_key` | No | No | The path to the key. | |
| `tls_client_ca` | No | No | The path to the trusted certificate authority list. | |
| `use_pkce` | No | Yes | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `false` |
| `use_refresh_token` | No | Yes | Set to `true` to use refresh token and check access token expiration. | `false` |
| `signout_redirect_url` | No | Yes | URL to redirect to after the user logs out. | |
## Examples of setting up Generic OAuth

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 {

View File

@ -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)

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

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

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])