From 89878dae1b14f72762fc5977e35202f294a6fd6b Mon Sep 17 00:00:00 2001 From: David Lamb <58757897+klarrio-dlamb@users.noreply.github.com> Date: Tue, 14 Sep 2021 02:15:15 +1000 Subject: [PATCH] OAuth: clarify role & group paths prefer id_token over userinfo api (#39066) * OAuth: clarify role & group paths prefer id_token over userinfo api (#39066) Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> Co-authored-by: Kevin Minehart --- docs/sources/auth/generic-oauth.md | 20 +++++++----- pkg/login/social/generic_oauth.go | 16 +++++----- pkg/login/social/generic_oauth_test.go | 42 ++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/docs/sources/auth/generic-oauth.md b/docs/sources/auth/generic-oauth.md index f27edd968cd..b348352ffcb 100755 --- a/docs/sources/auth/generic-oauth.md +++ b/docs/sources/auth/generic-oauth.md @@ -55,26 +55,30 @@ You can also specify the SSL/TLS configuration used by the client. Set `empty_scopes` to true to use an empty scope during authentication. By default, Grafana uses `user:email` as scope. -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: +### Email address + +Grafana determines a user's email address by querying the OAuth provider until it finds an e-mail address: 1. Check for the presence of an e-mail address via the `email` field encoded in the OAuth `id_token` parameter. 1. Check for the presence of an e-mail address using the [JMESPath](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+. 1. 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. -1. 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. -1. 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. +1. Query the `/emails` endpoint of the OAuth provider's API (configured with `api_url`), then check for the presence of an email address marked as a primary address. +1. If no email address is found in steps (1-4), then the email address of the user is set to an empty string. -Grafana will also attempt to do role mapping through OAuth as described below. +### Roles -Check for the presence of a role using the [JMESPath](http://jmespath.org/examples.html) specified via the `role_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. The result after evaluating the `role_attribute_path` JMESPath expression needs to be a valid Grafana role, i.e. `Viewer`, `Editor` or `Admin`. +Grafana checks for the presence of a role using the [JMESPath](http://jmespath.org/examples.html) specified via the `role_attribute_path` configuration option. The JMESPath is applied to the `id_token` first. If there is no match, then the UserInfo endpoint specified via the `api_url` configuration option is tried next. The result after evaluation of the `role_attribute_path` JMESPath expression should be a valid Grafana role, for example, `Viewer`, `Editor` or `Admin`. -Grafana also attempts to map teams through OAuth as described below. +For more information, refer to the [JMESPath examples](#jmespath-examples). -Check for the presence of groups using the [JMESPath](http://jmespath.org/examples.html) specified via the `groups_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. After evaluating the `groups_attribute_path` JMESPath expression, the result should be a string array of groups. +### Groups / Teams + +Similarly, group mappings are made using [JMESPath](http://jmespath.org/examples.html) with the `groups_attribute_path` configuration option. The `id_token` is attempted first, followed by the UserInfo from the `api_url`. The result of the JMESPath expression should be a string array of groups. Furthermore, Grafana will check for the presence of at least one of the teams specified via the `team_ids` configuration option using the [JMESPath](http://jmespath.org/examples.html) specified via the `team_ids_attribute_path` configuration option. The JSON used for the path lookup is the HTTP response obtained from querying the Teams endpoint specified via the `teams_url` configuration option (using `/teams` as a fallback endpoint). The result should be a string array of Grafana Team IDs. Using this setting ensures that only certain teams is allowed to authenticate to Grafana using your OAuth provider. -See [JMESPath examples](#jmespath-examples) for more information. +### Login Customize user login using `login_attribute_path` configuration option. Order of operations is as follows: diff --git a/pkg/login/social/generic_oauth.go b/pkg/login/social/generic_oauth.go index 9113f217df1..f07690d873e 100644 --- a/pkg/login/social/generic_oauth.go +++ b/pkg/login/social/generic_oauth.go @@ -149,19 +149,21 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) if userInfo.Role == "" { role, err := s.extractRole(data) if err != nil { - s.log.Error("Failed to extract role", "error", err) + s.log.Warn("Failed to extract role", "error", err) } else if role != "" { s.log.Debug("Setting user info role from extracted role") userInfo.Role = role } } - groups, err := s.extractGroups(data) - if err != nil { - s.log.Error("Failed to extract groups", "error", err) - } else if len(groups) > 0 { - s.log.Debug("Setting user info groups from extracted groups") - userInfo.Groups = groups + if userInfo.Groups != nil && 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 + } } } diff --git a/pkg/login/social/generic_oauth_test.go b/pkg/login/social/generic_oauth_test.go index 2bfcaa51b1b..c293e1be9ad 100644 --- a/pkg/login/social/generic_oauth_test.go +++ b/pkg/login/social/generic_oauth_test.go @@ -379,6 +379,48 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) { ExpectedEmail: "john.doe@example.com", ExpectedRole: "FromResponse", }, + { + Name: "Given a valid id_token, a valid advanced JMESPath role path, derive the role", + OAuth2Extra: map[string]interface{}{ + // { "email": "john.doe@example.com", + // "info": { "roles": [ "dev", "engineering" ] }} + "id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwiaW5mbyI6eyJyb2xlcyI6WyJkZXYiLCJlbmdpbmVlcmluZyJdfX0.RmmQfv25eXb4p3wMrJsvXfGQ6EXhGtwRXo6SlCFHRNg", + }, + RoleAttributePath: "contains(info.roles[*], 'dev') && 'Editor'", + ExpectedEmail: "john.doe@example.com", + ExpectedRole: "Editor", + }, + { + Name: "Given a valid id_token without role info, a valid advanced JMESPath role path, a valid API response, derive the correct role using the userinfo API response (JMESPath warning on id_token)", + OAuth2Extra: map[string]interface{}{ + // { "email": "john.doe@example.com" } + "id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.k5GwPcZvGe2BE_jgwN0ntz0nz4KlYhEd0hRRLApkTJ4", + }, + ResponseBody: map[string]interface{}{ + "info": map[string]interface{}{ + "roles": []string{"engineering", "SRE"}, + }, + }, + RoleAttributePath: "contains(info.roles[*], 'SRE') && 'Admin'", + ExpectedEmail: "john.doe@example.com", + ExpectedRole: "Admin", + }, + { + Name: "Given a valid id_token, a valid advanced JMESPath role path, a valid API response, prefer ID token", + OAuth2Extra: map[string]interface{}{ + // { "email": "john.doe@example.com", + // "info": { "roles": [ "dev", "engineering" ] }} + "id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwiaW5mbyI6eyJyb2xlcyI6WyJkZXYiLCJlbmdpbmVlcmluZyJdfX0.RmmQfv25eXb4p3wMrJsvXfGQ6EXhGtwRXo6SlCFHRNg", + }, + ResponseBody: map[string]interface{}{ + "info": map[string]interface{}{ + "roles": []string{"engineering", "SRE"}, + }, + }, + RoleAttributePath: "contains(info.roles[*], 'SRE') && 'Admin' || contains(info.roles[*], 'dev') && 'Editor' || 'Viewer'", + ExpectedEmail: "john.doe@example.com", + ExpectedRole: "Editor", + }, } for _, test := range tests {