mirror of
https://github.com/grafana/grafana.git
synced 2026-06-09 00:23:05 -04:00
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:
parent
703476b3ae
commit
7afdfd2ef4
21 changed files with 426 additions and 161 deletions
|
|
@ -386,6 +386,21 @@ token_url = https://login.microsoftonline.com/<tenant-id>/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://<tenant-id>.okta.com/oauth2/v1/authorize
|
||||
token_url = https://<tenant-id>.okta.com/oauth2/v1/token
|
||||
api_url = https://<tenant-id>.okta.com/oauth2/v1/userinfo
|
||||
allowed_domains =
|
||||
allowed_groups =
|
||||
role_attribute_path =
|
||||
|
||||
#################################### Generic OAuth #######################
|
||||
[auth.generic_oauth]
|
||||
name = OAuth
|
||||
|
|
|
|||
|
|
@ -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://<tenant-id>.okta.com/oauth2/v1/authorize
|
||||
;token_url = https://<tenant-id>.okta.com/oauth2/v1/token
|
||||
;api_url = https://<tenant-id>.okta.com/oauth2/v1/userinfo
|
||||
;allowed_domains =
|
||||
;allowed_groups =
|
||||
;role_attribute_path =
|
||||
|
||||
#################################### Generic OAuth ##########################
|
||||
[auth.generic_oauth]
|
||||
;enabled = false
|
||||
|
|
|
|||
89
docs/sources/auth/okta.md
Normal file
89
docs/sources/auth/okta.md
Normal file
|
|
@ -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://<tenant-id>.okta.com/oauth2/v1/authorize
|
||||
token_url = https://<tenant-id>.okta.com/oauth2/v1/token
|
||||
api_url = https://<tenant-id>.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" >}})
|
||||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -229,6 +229,11 @@ $external-services: (
|
|||
borderColor: #393939,
|
||||
icon: '',
|
||||
),
|
||||
okta: (
|
||||
bgColor: #2f2f2f,
|
||||
borderColor: #393939,
|
||||
icon: '',
|
||||
),
|
||||
oauth: (
|
||||
bgColor: #262628,
|
||||
borderColor: #393939,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
pkg/login/social/okta_oauth.go
Normal file
153
pkg/login/social/okta_oauth.go
Normal 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
|
||||
}
|
||||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,4 +10,5 @@ const (
|
|||
GRAFANA_COM
|
||||
GITLAB
|
||||
AZUREAD
|
||||
OKTA
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
BIN
public/img/okta_logo_white.png
Normal file
BIN
public/img/okta_logo_white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
|
|
@ -232,6 +232,11 @@ $external-services: (
|
|||
borderColor: #393939,
|
||||
icon: '',
|
||||
),
|
||||
okta: (
|
||||
bgColor: #2f2f2f,
|
||||
borderColor: #393939,
|
||||
icon: '',
|
||||
),
|
||||
oauth: (
|
||||
bgColor: #262628,
|
||||
borderColor: #393939,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue