Okta OAuth provider (team sync support) (#22972)

* Okta OAuth support

* Chore: fix linter error

* Chore: move IsEmailAllowed to SocialBase

* Chore: move IsSignupAllowed to SocialBase

* Chore: review fixes

* Okta: support allowed_groups

* Okta: default config

* Chore: move extractEmail() to OktaClaims struct

* Chore: review fixes

* generic_oauth_test: Handle error cases

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* generic_oauth_test: Handle error cases

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Docs: Okta OAuth

* Chore: don't return expected errors from searchJSONForAttr

* Docs: role mapping

* Chore: review fixes (searchJSONForAttr)

* Docs: review fixes

* Update docs/sources/auth/okta.md

Co-Authored-By: Arve Knudsen <arve.knudsen@gmail.com>

* Update docs/sources/auth/okta.md

Co-Authored-By: Arve Knudsen <arve.knudsen@gmail.com>

* Chore: log error if searchJSONForAttr failed

* Docs: add Okta login link

* Docs: review fixes

* Docs: add reference to the org roles

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>
This commit is contained in:
Alexander Zobnin
2020-04-02 17:35:48 +03:00
committed by GitHub
co-authored by Arve Knudsen
parent 703476b3ae
commit 7afdfd2ef4
21 changed files with 426 additions and 161 deletions
+6 -16
View File
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/util/errutil"
"golang.org/x/oauth2"
"gopkg.in/square/go-jose.v2/jwt"
@@ -14,9 +15,7 @@ import (
type SocialAzureAD struct {
*SocialBase
allowedDomains []string
allowedGroups []string
allowSignup bool
allowedGroups []string
}
type azureClaims struct {
@@ -32,34 +31,25 @@ func (s *SocialAzureAD) Type() int {
return int(models.AZUREAD)
}
func (s *SocialAzureAD) IsEmailAllowed(email string) bool {
return isEmailAllowed(email, s.allowedDomains)
}
func (s *SocialAzureAD) IsSignupAllowed() bool {
return s.allowSignup
}
func (s *SocialAzureAD) UserInfo(_ *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
idToken := token.Extra("id_token")
if idToken == nil {
return nil, fmt.Errorf("No id_token found")
return nil, fmt.Errorf("no id_token found")
}
parsedToken, err := jwt.ParseSigned(idToken.(string))
if err != nil {
return nil, fmt.Errorf("Error parsing id token")
return nil, errutil.Wrapf(err, "error parsing id token")
}
var claims azureClaims
if err := parsedToken.UnsafeClaimsWithoutVerification(&claims); err != nil {
return nil, fmt.Errorf("Error getting claims from id token")
return nil, errutil.Wrapf(err, "error getting claims from id token")
}
email := extractEmail(claims)
if email == "" {
return nil, errors.New("Error getting user info: No email found in access token")
return nil, errors.New("error getting user info: no email found in access token")
}
role := extractRole(claims)
+4 -8
View File
@@ -13,10 +13,8 @@ import (
func TestSocialAzureAD_UserInfo(t *testing.T) {
type fields struct {
SocialBase *SocialBase
allowedDomains []string
allowedGroups []string
allowSignup bool
SocialBase *SocialBase
allowedGroups []string
}
type args struct {
client *http.Client
@@ -225,10 +223,8 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &SocialAzureAD{
SocialBase: tt.fields.SocialBase,
allowedDomains: tt.fields.allowedDomains,
allowedGroups: tt.fields.allowedGroups,
allowSignup: tt.fields.allowSignup,
SocialBase: tt.fields.SocialBase,
allowedGroups: tt.fields.allowedGroups,
}
key := []byte("secret")
+39
View File
@@ -1,12 +1,16 @@
package social
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"strings"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/util/errutil"
"github.com/jmespath/go-jmespath"
)
var (
@@ -18,6 +22,14 @@ type HttpGetResponse struct {
Headers http.Header
}
func (s *SocialBase) IsEmailAllowed(email string) bool {
return isEmailAllowed(email, s.allowedDomains)
}
func (s *SocialBase) IsSignupAllowed() bool {
return s.allowSignup
}
func isEmailAllowed(email string, allowedDomains []string) bool {
if len(allowedDomains) == 0 {
return true
@@ -57,3 +69,30 @@ func HttpGet(client *http.Client, url string) (response HttpGetResponse, err err
err = nil
return
}
func (s *SocialBase) searchJSONForAttr(attributePath string, data []byte) (string, error) {
if attributePath == "" {
return "", errors.New("no attribute path specified")
}
if len(data) == 0 {
return "", errors.New("empty user info JSON response provided")
}
var buf interface{}
if err := json.Unmarshal(data, &buf); err != nil {
return "", errutil.Wrap("failed to unmarshal user info JSON response", err)
}
val, err := jmespath.Search(attributePath, buf)
if err != nil {
return "", errutil.Wrapf(err, "failed to search user info JSON response with provided path: %q", attributePath)
}
strVal, ok := val.(string)
if ok {
return strVal, nil
}
return "", nil
}
+19 -52
View File
@@ -12,16 +12,13 @@ import (
"github.com/grafana/grafana/pkg/util/errutil"
"github.com/grafana/grafana/pkg/models"
"github.com/jmespath/go-jmespath"
"golang.org/x/oauth2"
)
type SocialGenericOAuth struct {
*SocialBase
allowedDomains []string
allowedOrganizations []string
apiUrl string
allowSignup bool
emailAttributeName string
emailAttributePath string
roleAttributePath string
@@ -32,14 +29,6 @@ func (s *SocialGenericOAuth) Type() int {
return int(models.GENERIC)
}
func (s *SocialGenericOAuth) IsEmailAllowed(email string) bool {
return isEmailAllowed(email, s.allowedDomains)
}
func (s *SocialGenericOAuth) IsSignupAllowed() bool {
return s.allowSignup
}
func (s *SocialGenericOAuth) IsTeamMember(client *http.Client) bool {
if len(s.teamIds) == 0 {
return true
@@ -141,7 +130,12 @@ func (s *SocialGenericOAuth) fillUserInfo(userInfo *BasicUserInfo, data *UserInf
userInfo.Email = s.extractEmail(data)
}
if userInfo.Role == "" {
userInfo.Role = s.extractRole(data)
role, err := s.extractRole(data)
if err != nil {
s.log.Error("Failed to extract role", "error", err)
} else {
userInfo.Role = role
}
}
if userInfo.Name == "" {
userInfo.Name = s.extractName(data)
@@ -209,8 +203,10 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
}
if s.emailAttributePath != "" {
email := s.searchJSONForAttr(s.emailAttributePath, data.rawJSON)
if email != "" {
email, err := s.searchJSONForAttr(s.emailAttributePath, data.rawJSON)
if err != nil {
s.log.Error("Failed to search JSON for attribute", "error", err)
} else if email != "" {
return email
}
}
@@ -231,14 +227,16 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
return ""
}
func (s *SocialGenericOAuth) extractRole(data *UserInfoJson) string {
if s.roleAttributePath != "" {
role := s.searchJSONForAttr(s.roleAttributePath, data.rawJSON)
if role != "" {
return role
}
func (s *SocialGenericOAuth) extractRole(data *UserInfoJson) (string, error) {
if s.roleAttributePath == "" {
return "", nil
}
return ""
role, err := s.searchJSONForAttr(s.roleAttributePath, data.rawJSON)
if err != nil {
return "", err
}
return role, nil
}
func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson) string {
@@ -265,37 +263,6 @@ func (s *SocialGenericOAuth) extractName(data *UserInfoJson) string {
return ""
}
// searchJSONForAttr searches the provided JSON response for the given attribute
// using the configured attribute path associated with the generic OAuth
// provider.
// Returns an empty string if an attribute is not found.
func (s *SocialGenericOAuth) searchJSONForAttr(attributePath string, data []byte) string {
if attributePath == "" {
s.log.Error("No 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(attributePath, buf)
if err != nil {
s.log.Error("Failed to search user info JSON response with provided path", "attributePath", attributePath, "err", err.Error())
return ""
}
strVal, ok := val.(string)
if ok {
return strVal
}
s.log.Error("Attribute not found when searching JSON with provided path", "attributePath", attributePath)
return ""
}
func (s *SocialGenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) {
type Record struct {
Email string `json:"email"`
+20 -2
View File
@@ -28,24 +28,28 @@ func TestSearchJSONForEmail(t *testing.T) {
UserInfoJSONResponse []byte
EmailAttributePath string
ExpectedResult string
ExpectedError string
}{
{
Name: "Given an invalid user info JSON response",
UserInfoJSONResponse: []byte("{"),
EmailAttributePath: "attributes.email",
ExpectedResult: "",
ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input",
},
{
Name: "Given an empty user info JSON response and empty JMES path",
UserInfoJSONResponse: []byte{},
EmailAttributePath: "",
ExpectedResult: "",
ExpectedError: "no attribute path specified",
},
{
Name: "Given an empty user info JSON response and valid JMES path",
UserInfoJSONResponse: []byte{},
EmailAttributePath: "attributes.email",
ExpectedResult: "",
ExpectedError: "empty user info JSON response provided",
},
{
Name: "Given a simple user info JSON response and valid JMES path",
@@ -87,7 +91,12 @@ func TestSearchJSONForEmail(t *testing.T) {
for _, test := range tests {
provider.emailAttributePath = test.EmailAttributePath
t.Run(test.Name, func(t *testing.T) {
actualResult := provider.searchJSONForAttr(test.EmailAttributePath, test.UserInfoJSONResponse)
actualResult, err := provider.searchJSONForAttr(test.EmailAttributePath, test.UserInfoJSONResponse)
if test.ExpectedError == "" {
require.NoError(t, err, "Testing case %q", test.Name)
} else {
require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name)
}
require.Equal(t, test.ExpectedResult, actualResult)
})
}
@@ -107,24 +116,28 @@ func TestSearchJSONForRole(t *testing.T) {
UserInfoJSONResponse []byte
RoleAttributePath string
ExpectedResult string
ExpectedError string
}{
{
Name: "Given an invalid user info JSON response",
UserInfoJSONResponse: []byte("{"),
RoleAttributePath: "attributes.role",
ExpectedResult: "",
ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input",
},
{
Name: "Given an empty user info JSON response and empty JMES path",
UserInfoJSONResponse: []byte{},
RoleAttributePath: "",
ExpectedResult: "",
ExpectedError: "no attribute path specified",
},
{
Name: "Given an empty user info JSON response and valid JMES path",
UserInfoJSONResponse: []byte{},
RoleAttributePath: "attributes.role",
ExpectedResult: "",
ExpectedError: "empty user info JSON response provided",
},
{
Name: "Given a simple user info JSON response and valid JMES path",
@@ -141,7 +154,12 @@ func TestSearchJSONForRole(t *testing.T) {
for _, test := range tests {
provider.roleAttributePath = test.RoleAttributePath
t.Run(test.Name, func(t *testing.T) {
actualResult := provider.searchJSONForAttr(test.RoleAttributePath, test.UserInfoJSONResponse)
actualResult, err := provider.searchJSONForAttr(test.RoleAttributePath, test.UserInfoJSONResponse)
if test.ExpectedError == "" {
require.NoError(t, err, "Testing case %q", test.Name)
} else {
require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name)
}
require.Equal(t, test.ExpectedResult, actualResult)
})
}
-10
View File
@@ -14,10 +14,8 @@ import (
type SocialGithub struct {
*SocialBase
allowedDomains []string
allowedOrganizations []string
apiUrl string
allowSignup bool
teamIds []int
}
@@ -39,14 +37,6 @@ func (s *SocialGithub) Type() int {
return int(models.GITHUB)
}
func (s *SocialGithub) IsEmailAllowed(email string) bool {
return isEmailAllowed(email, s.allowedDomains)
}
func (s *SocialGithub) IsSignupAllowed() bool {
return s.allowSignup
}
func (s *SocialGithub) IsTeamMember(client *http.Client) bool {
if len(s.teamIds) == 0 {
return true
+2 -12
View File
@@ -13,24 +13,14 @@ import (
type SocialGitlab struct {
*SocialBase
allowedDomains []string
allowedGroups []string
apiUrl string
allowSignup bool
allowedGroups []string
apiUrl string
}
func (s *SocialGitlab) Type() int {
return int(models.GITLAB)
}
func (s *SocialGitlab) IsEmailAllowed(email string) bool {
return isEmailAllowed(email, s.allowedDomains)
}
func (s *SocialGitlab) IsSignupAllowed() bool {
return s.allowSignup
}
func (s *SocialGitlab) IsGroupMember(groups []string) bool {
if len(s.allowedGroups) == 0 {
return true
+2 -12
View File
@@ -12,24 +12,14 @@ import (
type SocialGoogle struct {
*SocialBase
allowedDomains []string
hostedDomain string
apiUrl string
allowSignup bool
hostedDomain string
apiUrl string
}
func (s *SocialGoogle) Type() int {
return int(models.GOOGLE)
}
func (s *SocialGoogle) IsEmailAllowed(email string) bool {
return isEmailAllowed(email, s.allowedDomains)
}
func (s *SocialGoogle) IsSignupAllowed() bool {
return s.allowSignup
}
func (s *SocialGoogle) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
var data struct {
Id string `json:"id"`
-5
View File
@@ -14,7 +14,6 @@ type SocialGrafanaCom struct {
*SocialBase
url string
allowedOrganizations []string
allowSignup bool
}
type OrgRecord struct {
@@ -29,10 +28,6 @@ func (s *SocialGrafanaCom) IsEmailAllowed(email string) bool {
return true
}
func (s *SocialGrafanaCom) IsSignupAllowed() bool {
return s.allowSignup
}
func (s *SocialGrafanaCom) IsOrganizationMember(organizations []OrgRecord) bool {
if len(s.allowedOrganizations) == 0 {
return true
+153
View File
@@ -0,0 +1,153 @@
package social
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/util/errutil"
"golang.org/x/oauth2"
"gopkg.in/square/go-jose.v2/jwt"
)
type SocialOkta struct {
*SocialBase
apiUrl string
allowedGroups []string
roleAttributePath string
}
type OktaUserInfoJson 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"`
Groups []string `json:"groups"`
rawJSON []byte
}
type OktaClaims struct {
ID string `json:"sub"`
Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
Name string `json:"name"`
}
func (claims *OktaClaims) extractEmail() string {
if claims.Email == "" && claims.PreferredUsername != "" {
return claims.PreferredUsername
}
return claims.Email
}
func (s *SocialOkta) Type() int {
return int(models.OKTA)
}
func (s *SocialOkta) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
idToken := token.Extra("id_token")
if idToken == nil {
return nil, fmt.Errorf("no id_token found")
}
parsedToken, err := jwt.ParseSigned(idToken.(string))
if err != nil {
return nil, errutil.Wrapf(err, "error parsing id token")
}
var claims OktaClaims
if err := parsedToken.UnsafeClaimsWithoutVerification(&claims); err != nil {
return nil, errutil.Wrapf(err, "error getting claims from id token")
}
email := claims.extractEmail()
if email == "" {
return nil, errors.New("error getting user info: no email found in access token")
}
var data OktaUserInfoJson
err = s.extractAPI(&data, client)
if err != nil {
return nil, err
}
role, err := s.extractRole(&data)
if err != nil {
s.log.Error("Failed to extract role", "error", err)
}
groups := s.GetGroups(&data)
if !s.IsGroupMember(groups) {
return nil, ErrMissingGroupMembership
}
return &BasicUserInfo{
Id: claims.ID,
Name: claims.Name,
Email: email,
Login: email,
Role: role,
Groups: groups,
}, nil
}
func (s *SocialOkta) extractAPI(data *OktaUserInfoJson, client *http.Client) error {
rawUserInfoResponse, err := HttpGet(client, s.apiUrl)
if err != nil {
s.log.Debug("Error getting user info response", "url", s.apiUrl, "error", err)
return errutil.Wrapf(err, "error getting user info response")
}
data.rawJSON = rawUserInfoResponse.Body
err = json.Unmarshal(data.rawJSON, data)
if err != nil {
s.log.Debug("Error decoding user info response", "raw_json", data.rawJSON, "error", err)
data.rawJSON = []byte{}
return errutil.Wrapf(err, "error decoding user info response")
}
s.log.Debug("Received user info response", "raw_json", string(data.rawJSON), "data", data)
return nil
}
func (s *SocialOkta) extractRole(data *OktaUserInfoJson) (string, error) {
if s.roleAttributePath == "" {
return "", nil
}
role, err := s.searchJSONForAttr(s.roleAttributePath, data.rawJSON)
if err != nil {
return "", err
}
return role, nil
}
func (s *SocialOkta) GetGroups(data *OktaUserInfoJson) []string {
groups := make([]string, 0)
if len(data.Groups) > 0 {
groups = data.Groups
}
return groups
}
func (s *SocialOkta) IsGroupMember(groups []string) bool {
if len(s.allowedGroups) == 0 {
return true
}
for _, allowedGroup := range s.allowedGroups {
for _, group := range groups {
if group == allowedGroup {
return true
}
}
}
return false
}
+36 -44
View File
@@ -37,7 +37,9 @@ type SocialConnector interface {
type SocialBase struct {
*oauth2.Config
log log.Logger
log log.Logger
allowSignup bool
allowedDomains []string
}
type Error struct {
@@ -55,9 +57,20 @@ const (
var (
SocialBaseUrl = "/login/"
SocialMap = make(map[string]SocialConnector)
allOauthes = []string{"github", "gitlab", "google", "generic_oauth", "grafananet", grafanaCom, "azuread"}
allOauthes = []string{"github", "gitlab", "google", "generic_oauth", "grafananet", grafanaCom, "azuread", "okta"}
)
func newSocialBase(name string, config *oauth2.Config, info *setting.OAuthInfo) *SocialBase {
logger := log.New("oauth." + name)
return &SocialBase{
Config: config,
log: logger,
allowSignup: info.AllowSignup,
allowedDomains: info.AllowedDomains,
}
}
func NewOAuthService() {
setting.OAuthService = &setting.OAuther{}
setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo)
@@ -107,18 +120,11 @@ func NewOAuthService() {
Scopes: info.Scopes,
}
logger := log.New("oauth." + name)
// GitHub.
if name == "github" {
SocialMap["github"] = &SocialGithub{
SocialBase: &SocialBase{
Config: &config,
log: logger,
},
allowedDomains: info.AllowedDomains,
SocialBase: newSocialBase(name, &config, info),
apiUrl: info.ApiUrl,
allowSignup: info.AllowSignup,
teamIds: sec.Key("team_ids").Ints(","),
allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()),
}
@@ -127,54 +133,44 @@ func NewOAuthService() {
// GitLab.
if name == "gitlab" {
SocialMap["gitlab"] = &SocialGitlab{
SocialBase: &SocialBase{
Config: &config,
log: logger,
},
allowedDomains: info.AllowedDomains,
apiUrl: info.ApiUrl,
allowSignup: info.AllowSignup,
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
SocialBase: newSocialBase(name, &config, info),
apiUrl: info.ApiUrl,
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
}
}
// Google.
if name == "google" {
SocialMap["google"] = &SocialGoogle{
SocialBase: &SocialBase{
Config: &config,
log: logger,
},
allowedDomains: info.AllowedDomains,
hostedDomain: info.HostedDomain,
apiUrl: info.ApiUrl,
allowSignup: info.AllowSignup,
SocialBase: newSocialBase(name, &config, info),
hostedDomain: info.HostedDomain,
apiUrl: info.ApiUrl,
}
}
// AzureAD.
if name == "azuread" {
SocialMap["azuread"] = &SocialAzureAD{
SocialBase: &SocialBase{
Config: &config,
log: logger,
},
allowedDomains: info.AllowedDomains,
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
allowSignup: info.AllowSignup,
SocialBase: newSocialBase(name, &config, info),
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
}
}
// Okta
if name == "okta" {
SocialMap["okta"] = &SocialOkta{
SocialBase: newSocialBase(name, &config, info),
apiUrl: info.ApiUrl,
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
roleAttributePath: info.RoleAttributePath,
}
}
// Generic - Uses the same scheme as Github.
if name == "generic_oauth" {
SocialMap["generic_oauth"] = &SocialGenericOAuth{
SocialBase: &SocialBase{
Config: &config,
log: logger,
},
allowedDomains: info.AllowedDomains,
SocialBase: newSocialBase(name, &config, info),
apiUrl: info.ApiUrl,
allowSignup: info.AllowSignup,
emailAttributeName: info.EmailAttributeName,
emailAttributePath: info.EmailAttributePath,
roleAttributePath: info.RoleAttributePath,
@@ -197,12 +193,8 @@ func NewOAuthService() {
}
SocialMap[grafanaCom] = &SocialGrafanaCom{
SocialBase: &SocialBase{
Config: &config,
log: logger,
},
SocialBase: newSocialBase(name, &config, info),
url: setting.GrafanaComUrl,
allowSignup: info.AllowSignup,
allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()),
}
}
+1
View File
@@ -10,4 +10,5 @@ const (
GRAFANA_COM
GITLAB
AZUREAD
OKTA
)