diff --git a/conf/defaults.ini b/conf/defaults.ini index 1667c8a9406..bfe7e768e1f 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -386,6 +386,21 @@ token_url = https://login.microsoftonline.com//oauth2/v2.0/token allowed_domains = allowed_groups = +#################################### Okta OAuth ####################### +[auth.okta] +name = Okta +enabled = false +allow_sign_up = true +client_id = some_id +client_secret = some_secret +scopes = openid profile email groups +auth_url = https://.okta.com/oauth2/v1/authorize +token_url = https://.okta.com/oauth2/v1/token +api_url = https://.okta.com/oauth2/v1/userinfo +allowed_domains = +allowed_groups = +role_attribute_path = + #################################### Generic OAuth ####################### [auth.generic_oauth] name = OAuth diff --git a/conf/sample.ini b/conf/sample.ini index f2398014163..98b9be12187 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -376,6 +376,21 @@ ;allowed_domains = ;allowed_groups = +#################################### Okta OAuth ####################### +[auth.okta] +;name = Okta +;enabled = false +;allow_sign_up = true +;client_id = some_id +;client_secret = some_secret +;scopes = openid profile email groups +;auth_url = https://.okta.com/oauth2/v1/authorize +;token_url = https://.okta.com/oauth2/v1/token +;api_url = https://.okta.com/oauth2/v1/userinfo +;allowed_domains = +;allowed_groups = +;role_attribute_path = + #################################### Generic OAuth ########################## [auth.generic_oauth] ;enabled = false diff --git a/docs/sources/auth/okta.md b/docs/sources/auth/okta.md new file mode 100644 index 00000000000..1ee1b9691ce --- /dev/null +++ b/docs/sources/auth/okta.md @@ -0,0 +1,89 @@ ++++ +title = "Okta OAuth2 authentication" +description = "Grafana Okta OAuth Guide " +keywords = ["grafana", "configuration", "documentation", "oauth"] +type = "docs" +[menu.docs] +name = "Okta" +identifier = "okta_oauth2" +parent = "authentication" +weight = 3 ++++ + +# Okta OAuth2 authentication + +> Only available in Grafana v7.0+ + +The Okta authentication allows your Grafana users to log in by using an external Okta authorization server. + +## Create an Okta application + +Before you can sign a user in, you need to create an Okta application from the Okta Developer Console. + +1. Log in to the [Okta portal](https://login.okta.com/). + +1. Go to Admin and then select **Developer Console**. + +1. Select **Applications**, then **Add Application**. + +1. Pick **Web** as the platform. + +1. Enter a name for your application (or leave the default value). + +1. Add the **Base URI** of your application, such as https://grafana.example.com. + +1. Enter values for the **Login redirect URI**. Use **Base URI** and append it with `/login/okta`, for example: https://grafana.example.com/login/okta. + +1. Click **Done** to finish creating the Okta application. + +## Enable Okta Oauth in Grafana + +1. Add the following to the [Grafana configuration file]({{< relref "../installation/configuration.md#config-file-locations" >}}): + +```ini +[auth.okta] +name = Okta +enabled = true +allow_sign_up = true +client_id = some_id +client_secret = some_secret +scopes = openid profile email groups +auth_url = https://.okta.com/oauth2/v1/authorize +token_url = https://.okta.com/oauth2/v1/token +api_url = https://.okta.com/oauth2/v1/userinfo +allowed_domains = +allowed_groups = +role_attribute_path = +``` + +### Configure allowed groups and domains + +To limit access to authenticated users that are members of one or more groups, set `allowed_groups` +to a comma- or space-separated list of Okta groups. + +```ini +allowed_groups = Developers, Admins +``` + +The `allowed_domains` option limits access to the users belonging to the specific domains. Domains should be separated by space or comma. + +```ini +allowed_domains = mycompany.com mycompany.org +``` + +### Map roles + +Grafana can attempt to do role mapping through Okta OAuth. In order to achieve this, Grafana checks for the presence of a role using the [JMESPath](http://jmespath.org/examples.html) specified via the `role_attribute_path` configuration option. + +Grafana uses JSON obtained from querying the `/userinfo` endpoint for the path lookup. The result after evaluating the `role_attribute_path` JMESPath expression needs to be a valid Grafana role, i.e. `Viewer`, `Editor` or `Admin`. Refer to [Organization roles]({{< relref "../permissions/organization_roles.md" >}}) for more information about roles and permissions in Grafana. + +Read about how to [add custom claims](https://developer.okta.com/docs/guides/customize-tokens-returned-from-okta/add-custom-claim/) to the user info in Okta. Also, check Generic OAuth page for [JMESPath examples]({{< relref "generic-oauth.md/#jmespath-examples" >}}). + +### Team Sync (Enterprise only) + +Map your Okta groups to teams in Grafana so that your users will automatically be added to +the correct teams. + +Okta groups can be referenced by group name, like `Admins`. + +[Learn more about Team Sync]({{< relref "../enterprise/team-sync.md" >}}) diff --git a/docs/sources/menu.yaml b/docs/sources/menu.yaml index 6dacae23540..58a3fbe5eed 100644 --- a/docs/sources/menu.yaml +++ b/docs/sources/menu.yaml @@ -56,6 +56,8 @@ name: GitHub - link: /auth/gitlab/ name: GitLab + - link: /auth/okta/ + name: Okta - link: /auth/saml/ name: SAML - link: /auth/team-sync/ diff --git a/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts b/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts index 10b7d551fe3..392336aaf66 100644 --- a/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts +++ b/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts @@ -229,6 +229,11 @@ $external-services: ( borderColor: #393939, icon: '', ), + okta: ( + bgColor: #2f2f2f, + borderColor: #393939, + icon: '', + ), oauth: ( bgColor: #262628, borderColor: #393939, diff --git a/pkg/login/social/azuread_oauth.go b/pkg/login/social/azuread_oauth.go index 620a232109c..3e40d4ce831 100644 --- a/pkg/login/social/azuread_oauth.go +++ b/pkg/login/social/azuread_oauth.go @@ -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) diff --git a/pkg/login/social/azuread_oauth_test.go b/pkg/login/social/azuread_oauth_test.go index e7e2ee97033..7fc04a9b733 100644 --- a/pkg/login/social/azuread_oauth_test.go +++ b/pkg/login/social/azuread_oauth_test.go @@ -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") diff --git a/pkg/login/social/common.go b/pkg/login/social/common.go index 053379f39bd..1f4f8adbfe1 100644 --- a/pkg/login/social/common.go +++ b/pkg/login/social/common.go @@ -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 +} diff --git a/pkg/login/social/generic_oauth.go b/pkg/login/social/generic_oauth.go index a4e10e05479..7a91000fb59 100644 --- a/pkg/login/social/generic_oauth.go +++ b/pkg/login/social/generic_oauth.go @@ -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"` diff --git a/pkg/login/social/generic_oauth_test.go b/pkg/login/social/generic_oauth_test.go index f42467820f1..a3a82c9ec1e 100644 --- a/pkg/login/social/generic_oauth_test.go +++ b/pkg/login/social/generic_oauth_test.go @@ -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) }) } diff --git a/pkg/login/social/github_oauth.go b/pkg/login/social/github_oauth.go index abd47fcb257..1ef08c60212 100644 --- a/pkg/login/social/github_oauth.go +++ b/pkg/login/social/github_oauth.go @@ -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 diff --git a/pkg/login/social/gitlab_oauth.go b/pkg/login/social/gitlab_oauth.go index 93dbb07fd2c..7e59851cbc7 100644 --- a/pkg/login/social/gitlab_oauth.go +++ b/pkg/login/social/gitlab_oauth.go @@ -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 diff --git a/pkg/login/social/google_oauth.go b/pkg/login/social/google_oauth.go index 05ae2a481f2..60e1e4568bd 100644 --- a/pkg/login/social/google_oauth.go +++ b/pkg/login/social/google_oauth.go @@ -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"` diff --git a/pkg/login/social/grafana_com_oauth.go b/pkg/login/social/grafana_com_oauth.go index 87601788c3f..4049aa9b8bd 100644 --- a/pkg/login/social/grafana_com_oauth.go +++ b/pkg/login/social/grafana_com_oauth.go @@ -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 diff --git a/pkg/login/social/okta_oauth.go b/pkg/login/social/okta_oauth.go new file mode 100644 index 00000000000..4fb30c4bea5 --- /dev/null +++ b/pkg/login/social/okta_oauth.go @@ -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 +} diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index 53be4ed6291..aa65d9e0240 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -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()), } } diff --git a/pkg/models/models.go b/pkg/models/models.go index 2c0c653ceb6..777c5297b6c 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -10,4 +10,5 @@ const ( GRAFANA_COM GITLAB AZUREAD + OKTA ) diff --git a/public/app/core/components/Login/LoginServiceButtons.tsx b/public/app/core/components/Login/LoginServiceButtons.tsx index 22a54ca5327..026990ac93d 100644 --- a/public/app/core/components/Login/LoginServiceButtons.tsx +++ b/public/app/core/components/Login/LoginServiceButtons.tsx @@ -33,6 +33,10 @@ const loginServices: () => LoginServices = () => { hrefName: 'grafana_com', icon: 'grafana_com', }, + okta: { + enabled: config.oauth.okta, + name: 'Okta', + }, oauth: { enabled: oauthEnabled && config.oauth.generic_oauth, name: oauthEnabled && config.oauth.generic_oauth ? config.oauth.generic_oauth.name : 'OAuth', diff --git a/public/img/okta_logo_white.png b/public/img/okta_logo_white.png new file mode 100644 index 00000000000..94e1e326dc7 Binary files /dev/null and b/public/img/okta_logo_white.png differ diff --git a/public/sass/_variables.generated.scss b/public/sass/_variables.generated.scss index 4875acf5957..3f03aad0c45 100644 --- a/public/sass/_variables.generated.scss +++ b/public/sass/_variables.generated.scss @@ -232,6 +232,11 @@ $external-services: ( borderColor: #393939, icon: '', ), + okta: ( + bgColor: #2f2f2f, + borderColor: #393939, + icon: '', + ), oauth: ( bgColor: #262628, borderColor: #393939, diff --git a/public/sass/components/_buttons.scss b/public/sass/components/_buttons.scss index f2649faedbb..89280a78d35 100644 --- a/public/sass/components/_buttons.scss +++ b/public/sass/components/_buttons.scss @@ -225,6 +225,15 @@ $btn-service-icon-width: 35px; } } +.btn-service--okta { + .btn-service-icon { + background-image: url(../img/okta_logo_white.png); + background-repeat: no-repeat; + background-position: 50%; + background-size: 60%; + } +} + //Toggle button .toggle-btn {