Auth: Support google OIDC and group fetching (#70140)

* Auth: Update Google OAuth default configuration based on /.well-known/openid-configuration #69520

Signed-off-by: junya koyama <arukiidou@yahoo.co.jp>

* add id_token parsing

add legacy API distinction

use google auth oidc connectors

add group fetching support and tests

* Apply suggestions from code review

Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com>
Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>

* implement review feedback

* indent docs

---------

Signed-off-by: junya koyama <arukiidou@yahoo.co.jp>
Co-authored-by: junya koyama <arukiidou@yahoo.co.jp>
Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com>
Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>
This commit is contained in:
Jo 2023-06-26 09:44:57 +02:00 committed by GitHub
parent 80226291a1
commit 11d196eb6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 717 additions and 28 deletions

View File

@ -631,10 +631,10 @@ allow_sign_up = true
auto_login = false
client_id = some_client_id
client_secret =
scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email
auth_url = https://accounts.google.com/o/oauth2/auth
token_url = https://accounts.google.com/o/oauth2/token
api_url = https://www.googleapis.com/oauth2/v1/userinfo
scopes = openid email profile
auth_url = https://accounts.google.com/o/oauth2/v2/auth
token_url = https://oauth2.googleapis.com/token
api_url = https://openidconnect.googleapis.com/v1/userinfo
allowed_domains =
hosted_domain =
skip_org_role_sync = false

View File

@ -616,10 +616,10 @@
;auto_login = false
;client_id = some_client_id
;client_secret = some_client_secret
;scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email
;auth_url = https://accounts.google.com/o/oauth2/auth
;token_url = https://accounts.google.com/o/oauth2/token
;api_url = https://www.googleapis.com/oauth2/v1/userinfo
;scopes = openid email profile
;auth_url = https://accounts.google.com/o/oauth2/v2/auth
;token_url = https://oauth2.googleapis.com/token
;api_url = https://openidconnect.googleapis.com/v1/userinfo
;allowed_domains =
;hosted_domain =
;skip_org_role_sync = false

View File

@ -36,9 +36,10 @@ allow_sign_up = true
auto_login = false
client_id = CLIENT_ID
client_secret = CLIENT_SECRET
scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email
auth_url = https://accounts.google.com/o/oauth2/auth
token_url = https://accounts.google.com/o/oauth2/token
scopes = openid email profile
auth_url = https://accounts.google.com/o/oauth2/v2/auth
token_url = https://oauth2.googleapis.com/token
api_url = https://openidconnect.googleapis.com/v1/userinfo
allowed_domains = mycompany.com mycompany.org
hosted_domain = mycompany.com
use_pkce = true
@ -98,3 +99,26 @@ We do not currently sync roles from Google and instead set the AutoAssigned role
# ..
skip_org_role_sync = true
```
### Configure team sync for Google OAuth
> Available in Grafana v10.1.0 and later versions.
With team sync, you can easily add users to teams by utilizing their Google groups. To set up team sync for Google OAuth, refer to the following example.
1. Enable the Google Cloud Identity API on your [organization's dashboard](https://console.cloud.google.com/apis/api/cloudidentity.googleapis.com/).
1. Add the `https://www.googleapis.com/auth/cloud-identity.groups.readonly` scope to your Grafana `[auth.google]` configuration:
Example:
```ini
[auth.google]
# ..
scopes = openid email profile https://www.googleapis.com/auth/cloud-identity.groups.readonly
```
1. Configure team sync in your Grafana team's `External group sync` tab.
The external group ID for a Google group is the group's email address, such as `dev@grafana.com`.
To learn more about Team Sync, refer to [Configure Team Sync]({{< relref "../../configure-team-sync" >}}).

View File

@ -5,41 +5,112 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"golang.org/x/exp/slices"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
)
const legacyAPIURL = "https://www.googleapis.com/oauth2/v1/userinfo"
const googleIAMGroupsEndpoint = "https://content-cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups"
const googleIAMScope = "https://www.googleapis.com/auth/cloud-identity.groups.readonly"
type SocialGoogle struct {
*SocialBase
hostedDomain string
apiUrl string
}
type googleUserData struct {
ID string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
EmailVerified bool `json:"email_verified"`
}
func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
var data struct {
Id string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
data, errToken := s.extractFromToken(ctx, client, token)
if errToken != nil {
return nil, errToken
}
if data == nil {
var errAPI error
data, errAPI = s.extractFromAPI(ctx, client)
if errAPI != nil {
return nil, errAPI
}
}
if data.ID == "" {
return nil, fmt.Errorf("error getting user info: id is empty")
}
if !data.EmailVerified {
return nil, fmt.Errorf("user email is not verified")
}
groups, errPage := s.retrieveGroups(ctx, client, data)
if errPage != nil {
s.log.Warn("Error retrieving groups", "error", errPage)
}
userInfo := &BasicUserInfo{
Id: data.ID,
Name: data.Name,
Email: data.Email,
Login: data.Email,
Role: "",
IsGrafanaAdmin: nil,
Groups: groups,
}
s.log.Debug("Resolved user info", "data", fmt.Sprintf("%+v", userInfo))
return userInfo, nil
}
type googleAPIData struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
EmailVerified bool `json:"verified_email"`
}
func (s *SocialGoogle) extractFromAPI(ctx context.Context, client *http.Client) (*googleUserData, error) {
if strings.HasPrefix(s.apiUrl, legacyAPIURL) {
data := googleAPIData{}
response, err := s.httpGet(ctx, client, s.apiUrl)
if err != nil {
return nil, fmt.Errorf("error retrieving legacy user info: %s", err)
}
if err := json.Unmarshal(response.Body, &data); err != nil {
return nil, fmt.Errorf("error unmarshalling legacy user info: %s", err)
}
return &googleUserData{
ID: data.ID,
Name: data.Name,
Email: data.Email,
EmailVerified: data.EmailVerified,
}, nil
}
data := googleUserData{}
response, err := s.httpGet(ctx, client, s.apiUrl)
if err != nil {
return nil, fmt.Errorf("Error getting user info: %s", err)
return nil, fmt.Errorf("error getting user info: %s", err)
}
err = json.Unmarshal(response.Body, &data)
if err != nil {
return nil, fmt.Errorf("Error getting user info: %s", err)
if err := json.Unmarshal(response.Body, &data); err != nil {
return nil, fmt.Errorf("error unmarshalling user info: %s", err)
}
return &BasicUserInfo{
Id: data.Id,
Name: data.Name,
Email: data.Email,
Login: data.Email,
}, nil
return &data, nil
}
func (s *SocialGoogle) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
@ -48,3 +119,88 @@ func (s *SocialGoogle) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption)
}
return s.SocialBase.AuthCodeURL(state, opts...)
}
func (s *SocialGoogle) extractFromToken(ctx context.Context, client *http.Client, token *oauth2.Token) (*googleUserData, error) {
s.log.Debug("Extracting user info from OAuth token")
idToken := token.Extra("id_token")
if idToken == nil {
s.log.Debug("No id_token found, defaulting to API access", "token", token)
return nil, nil
}
rawJSON, err := s.retrieveRawIDToken(idToken)
if err != nil {
s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", idToken))
return nil, nil
}
if setting.Env == setting.Dev {
s.log.Debug("Received id_token", "raw_json", string(rawJSON))
}
var data googleUserData
if err := json.Unmarshal(rawJSON, &data); err != nil {
return nil, fmt.Errorf("Error getting user info: %s", err)
}
return &data, nil
}
type googleGroupResp struct {
Memberships []struct {
Group string `json:"group"`
GroupKey struct {
ID string `json:"id"`
} `json:"groupKey"`
DisplayName string `json:"displayName"`
} `json:"memberships"`
NextPageToken string `json:"nextPageToken"`
}
func (s *SocialGoogle) retrieveGroups(ctx context.Context, client *http.Client, userData *googleUserData) ([]string, error) {
s.log.Debug("Retrieving groups", "scopes", s.SocialBase.Config.Scopes)
if !slices.Contains(s.Scopes, googleIAMScope) {
return nil, nil
}
groups := []string{}
url := fmt.Sprintf("%s?query=member_key_id=='%s'", googleIAMGroupsEndpoint, userData.Email)
nextPageToken := ""
for page, errPage := s.getGroupsPage(ctx, client, url, nextPageToken); ; page, errPage = s.getGroupsPage(ctx, client, url, nextPageToken) {
if errPage != nil {
return nil, errPage
}
for _, group := range page.Memberships {
groups = append(groups, group.GroupKey.ID)
}
nextPageToken = page.NextPageToken
if nextPageToken == "" {
break
}
}
return groups, nil
}
func (s *SocialGoogle) getGroupsPage(ctx context.Context, client *http.Client, url, nextPageToken string) (*googleGroupResp, error) {
if nextPageToken != "" {
url = fmt.Sprintf("%s&pageToken=%s", url, nextPageToken)
}
s.log.Debug("Retrieving groups", "url", url)
resp, err := s.httpGet(ctx, client, url)
if err != nil {
return nil, fmt.Errorf("error getting groups: %s", err)
}
var data googleGroupResp
if err := json.Unmarshal(resp.Body, &data); err != nil {
return nil, fmt.Errorf("error unmarshalling groups: %s", err)
}
return &data, nil
}

View File

@ -0,0 +1,504 @@
package social
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/infra/log"
)
func TestSocialGoogle_retrieveGroups(t *testing.T) {
type fields struct {
Scopes []string
}
type args struct {
client *http.Client
userData *googleUserData
}
tests := []struct {
name string
fields fields
args args
want []string
wantErr bool
}{
{
name: "No scope",
fields: fields{
Scopes: []string{},
},
args: args{
client: &http.Client{},
userData: &googleUserData{
Email: "test@example.com",
},
},
want: nil,
wantErr: false,
},
{
name: "No groups",
fields: fields{
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
},
args: args{
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(`{
"memberships": [
],
"nextPageToken": ""
}`)
return resp.Result(), nil
},
},
},
userData: &googleUserData{
Email: "test@example.com",
},
},
want: []string{},
wantErr: false,
},
{
name: "error retrieving groups",
fields: fields{
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
},
args: args{
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
return nil, errors.New("error retrieving groups")
},
},
},
userData: &googleUserData{
Email: "test@example.com",
},
},
want: nil,
wantErr: true,
},
{
name: "Has 2 pages to fetch",
fields: fields{
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
},
args: args{
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
// First page
if req.URL.Query().Get("pageToken") == "" {
_, _ = resp.WriteString(`{
"memberships": [
{
"group": "test-group",
"groupKey": {
"id": "test-group@google.com"
},
"displayName": "Test Group"
}
],
"nextPageToken": "page-2"
}`)
} else {
// Second page
_, _ = resp.WriteString(`{
"memberships": [
{
"group": "test-group-2",
"groupKey": {
"id": "test-group-2@google.com"
},
"displayName": "Test Group 2"
}
],
"nextPageToken": ""
}`)
}
return resp.Result(), nil
},
},
},
userData: &googleUserData{
Email: "test@example.com",
},
},
want: []string{"test-group@google.com", "test-group-2@google.com"},
wantErr: false,
},
{
name: "Has one page to fetch",
fields: fields{
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
},
args: args{
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(`{
"memberships": [
{
"group": "test-group",
"groupKey": {
"id": "test-group@google.com"
},
"displayName": "Test Group"
}
],
"nextPageToken": ""
}`)
return resp.Result(), nil
},
},
},
userData: &googleUserData{
Email: "test@example.com",
},
},
want: []string{"test-group@google.com"},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &SocialGoogle{
SocialBase: &SocialBase{
Config: &oauth2.Config{Scopes: tt.fields.Scopes},
log: log.NewNopLogger(),
allowSignup: false,
allowAssignGrafanaAdmin: false,
allowedDomains: []string{},
roleAttributePath: "",
roleAttributeStrict: false,
autoAssignOrgRole: "",
skipOrgRoleSync: false,
},
hostedDomain: "",
apiUrl: "",
}
got, err := s.retrieveGroups(context.Background(), tt.args.client, tt.args.userData)
if (err != nil) != tt.wantErr {
t.Errorf("SocialGoogle.retrieveGroups() error = %v, wantErr %v", err, tt.wantErr)
return
}
require.Equal(t, tt.want, got)
})
}
}
type roundTripperFunc struct {
fn func(req *http.Request) (*http.Response, error)
}
func (f *roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f.fn(req)
}
func TestSocialGoogle_UserInfo(t *testing.T) {
cl := jwt.Claims{
Subject: "88888888888888",
Issuer: "issuer",
NotBefore: jwt.NewNumericDate(time.Date(2016, 1, 1, 0, 0, 0, 0, time.UTC)),
Audience: jwt.Audience{"823123"},
}
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: []byte("secret")}, (&jose.SignerOptions{}).WithType("JWT"))
require.NoError(t, err)
idMap := map[string]interface{}{
"email": "test@example.com",
"name": "Test User",
"hd": "example.com",
"email_verified": true,
}
raw, err := jwt.Signed(sig).Claims(cl).Claims(idMap).CompactSerialize()
require.NoError(t, err)
tokenWithID := (&oauth2.Token{
AccessToken: "fake_token",
}).WithExtra(map[string]interface{}{"id_token": raw})
tokenWithoutID := &oauth2.Token{}
type fields struct {
Scopes []string
apiURL string
}
type args struct {
client *http.Client
token *oauth2.Token
}
tests := []struct {
name string
fields fields
args args
wantData *BasicUserInfo
wantErr bool
wantErrMsg string
}{
{
name: "Success id_token",
fields: fields{
Scopes: []string{},
},
args: args{
token: tokenWithID,
},
wantData: &BasicUserInfo{
Id: "88888888888888",
Login: "test@example.com",
Email: "test@example.com",
Name: "Test User",
},
wantErr: false,
},
{
name: "Success id_token - groups requested",
fields: fields{
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
},
args: args{
token: tokenWithID,
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(`{
"memberships": [
{
"group": "test-group",
"groupKey": {
"id": "test-group@google.com"
},
"displayName": "Test Group"
}
],
"nextPageToken": ""
}`)
return resp.Result(), nil
},
},
},
},
wantData: &BasicUserInfo{
Id: "88888888888888",
Login: "test@example.com",
Email: "test@example.com",
Name: "Test User",
Groups: []string{"test-group@google.com"},
},
wantErr: false,
},
{
name: "Legacy API URL",
fields: fields{
apiURL: legacyAPIURL,
},
args: args{
token: tokenWithoutID,
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(`{
"id": "99999999999999",
"name": "Test User",
"email": "test@example.com",
"verified_email": true
}`)
return resp.Result(), nil
},
},
},
},
wantData: &BasicUserInfo{
Id: "99999999999999",
Login: "test@example.com",
Email: "test@example.com",
Name: "Test User",
},
wantErr: false,
},
{
name: "Legacy API URL - no id provided",
fields: fields{
apiURL: legacyAPIURL,
},
args: args{
token: tokenWithoutID,
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(`{
"name": "Test User",
"email": "test@example.com",
"verified_email": true
}`)
return resp.Result(), nil
},
},
},
},
wantData: nil,
wantErr: true,
wantErrMsg: "error getting user info: id is empty",
},
{
name: "Error unmarshalling legacy user info",
fields: fields{
apiURL: legacyAPIURL,
},
args: args{
token: tokenWithoutID,
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(`invalid json`)
return resp.Result(), nil
},
},
},
},
wantData: nil,
wantErr: true,
wantErrMsg: "error unmarshalling legacy user info",
},
{
name: "Error getting user info",
fields: fields{
apiURL: "https://openidconnect.googleapis.com/v1/userinfo",
},
args: args{
token: tokenWithoutID,
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
return nil, errors.New("error getting user info")
},
},
},
},
wantData: nil,
wantErr: true,
wantErrMsg: "error getting user info",
},
{
name: "Error unmarshalling user info",
fields: fields{
apiURL: "https://openidconnect.googleapis.com/v1/userinfo",
},
args: args{
token: tokenWithoutID,
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(`invalid json`)
return resp.Result(), nil
},
},
},
},
wantData: nil,
wantErr: true,
wantErrMsg: "error unmarshalling user info",
},
{
name: "Success",
fields: fields{
apiURL: "https://openidconnect.googleapis.com/v1/userinfo",
},
args: args{
token: tokenWithoutID,
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(`{
"sub": "92222222222222222",
"name": "Test User",
"email": "test@example.com",
"email_verified": true
}`)
return resp.Result(), nil
},
},
},
},
wantData: &BasicUserInfo{
Id: "92222222222222222",
Name: "Test User",
Email: "test@example.com",
Login: "test@example.com",
},
wantErr: false,
}, {
name: "Unverified Email userinfo",
fields: fields{
apiURL: "https://openidconnect.googleapis.com/v1/userinfo",
},
args: args{
token: tokenWithoutID,
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(`{
"sub": "92222222222222222",
"name": "Test User",
"email": "test@example.com",
"email_verified": false
}`)
return resp.Result(), nil
},
},
},
},
wantData: nil,
wantErr: true,
wantErrMsg: "email is not verified",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &SocialGoogle{
apiUrl: tt.fields.apiURL,
SocialBase: &SocialBase{
Config: &oauth2.Config{Scopes: tt.fields.Scopes},
log: log.NewNopLogger(),
allowSignup: false,
},
}
gotData, err := s.UserInfo(context.Background(), tt.args.client, tt.args.token)
if tt.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErrMsg)
} else {
require.NoError(t, err)
assert.Equal(t, tt.wantData, gotData)
}
})
}
}

View File

@ -37,6 +37,7 @@ type SocialService struct {
socialMap map[string]SocialConnector
oAuthProvider map[string]*OAuthInfo
log log.Logger
}
type OAuthInfo struct {
@ -78,6 +79,7 @@ func ProvideService(cfg *setting.Cfg,
cfg: cfg,
oAuthProvider: make(map[string]*OAuthInfo),
socialMap: make(map[string]SocialConnector),
log: log.New("login.social"),
}
usageStats.RegisterMetricsFunc(ss.getUsageStats)
@ -138,7 +140,7 @@ func ProvideService(cfg *setting.Cfg,
case "autodetect", "":
authStyle = oauth2.AuthStyleAutoDetect
default:
logger.Warn("Invalid auth style specified, defaulting to auth style AutoDetect", "auth_style", sec.Key("auth_style").String())
ss.log.Warn("Invalid auth style specified, defaulting to auth style AutoDetect", "auth_style", sec.Key("auth_style").String())
authStyle = oauth2.AuthStyleAutoDetect
}
@ -177,6 +179,9 @@ func ProvideService(cfg *setting.Cfg,
// Google.
if name == "google" {
if strings.HasPrefix(info.ApiUrl, legacyAPIURL) {
ss.log.Warn("Using legacy Google API URL, please update your configuration")
}
ss.socialMap["google"] = &SocialGoogle{
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
hostedDomain: info.HostedDomain,
@ -475,7 +480,7 @@ func (ss *SocialService) GetOAuthHttpClient(name string) (*http.Client, error) {
if info.TlsClientCert != "" || info.TlsClientKey != "" {
cert, err := tls.LoadX509KeyPair(info.TlsClientCert, info.TlsClientKey)
if err != nil {
logger.Error("Failed to setup TlsClientCert", "oauth", name, "error", err)
ss.log.Error("Failed to setup TlsClientCert", "oauth", name, "error", err)
return nil, fmt.Errorf("failed to setup TlsClientCert: %w", err)
}
@ -485,7 +490,7 @@ func (ss *SocialService) GetOAuthHttpClient(name string) (*http.Client, error) {
if info.TlsClientCa != "" {
caCert, err := os.ReadFile(info.TlsClientCa)
if err != nil {
logger.Error("Failed to setup TlsClientCa", "oauth", name, "error", err)
ss.log.Error("Failed to setup TlsClientCa", "oauth", name, "error", err)
return nil, fmt.Errorf("failed to setup TlsClientCa: %w", err)
}
caCertPool := x509.NewCertPool()