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
co-authored by Christopher Moyer Ieva junya koyama
parent 80226291a1
commit 11d196eb6e
6 changed files with 717 additions and 28 deletions
+170 -14
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
}
+504
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)
}
})
}
}
+8 -3
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()