mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
80226291a1
commit
11d196eb6e
@ -631,10 +631,10 @@ allow_sign_up = true
|
|||||||
auto_login = false
|
auto_login = false
|
||||||
client_id = some_client_id
|
client_id = some_client_id
|
||||||
client_secret =
|
client_secret =
|
||||||
scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email
|
scopes = openid email profile
|
||||||
auth_url = https://accounts.google.com/o/oauth2/auth
|
auth_url = https://accounts.google.com/o/oauth2/v2/auth
|
||||||
token_url = https://accounts.google.com/o/oauth2/token
|
token_url = https://oauth2.googleapis.com/token
|
||||||
api_url = https://www.googleapis.com/oauth2/v1/userinfo
|
api_url = https://openidconnect.googleapis.com/v1/userinfo
|
||||||
allowed_domains =
|
allowed_domains =
|
||||||
hosted_domain =
|
hosted_domain =
|
||||||
skip_org_role_sync = false
|
skip_org_role_sync = false
|
||||||
|
@ -616,10 +616,10 @@
|
|||||||
;auto_login = false
|
;auto_login = false
|
||||||
;client_id = some_client_id
|
;client_id = some_client_id
|
||||||
;client_secret = some_client_secret
|
;client_secret = some_client_secret
|
||||||
;scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email
|
;scopes = openid email profile
|
||||||
;auth_url = https://accounts.google.com/o/oauth2/auth
|
;auth_url = https://accounts.google.com/o/oauth2/v2/auth
|
||||||
;token_url = https://accounts.google.com/o/oauth2/token
|
;token_url = https://oauth2.googleapis.com/token
|
||||||
;api_url = https://www.googleapis.com/oauth2/v1/userinfo
|
;api_url = https://openidconnect.googleapis.com/v1/userinfo
|
||||||
;allowed_domains =
|
;allowed_domains =
|
||||||
;hosted_domain =
|
;hosted_domain =
|
||||||
;skip_org_role_sync = false
|
;skip_org_role_sync = false
|
||||||
|
@ -36,9 +36,10 @@ allow_sign_up = true
|
|||||||
auto_login = false
|
auto_login = false
|
||||||
client_id = CLIENT_ID
|
client_id = CLIENT_ID
|
||||||
client_secret = CLIENT_SECRET
|
client_secret = CLIENT_SECRET
|
||||||
scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email
|
scopes = openid email profile
|
||||||
auth_url = https://accounts.google.com/o/oauth2/auth
|
auth_url = https://accounts.google.com/o/oauth2/v2/auth
|
||||||
token_url = https://accounts.google.com/o/oauth2/token
|
token_url = https://oauth2.googleapis.com/token
|
||||||
|
api_url = https://openidconnect.googleapis.com/v1/userinfo
|
||||||
allowed_domains = mycompany.com mycompany.org
|
allowed_domains = mycompany.com mycompany.org
|
||||||
hosted_domain = mycompany.com
|
hosted_domain = mycompany.com
|
||||||
use_pkce = true
|
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
|
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" >}}).
|
||||||
|
@ -5,41 +5,112 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"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 {
|
type SocialGoogle struct {
|
||||||
*SocialBase
|
*SocialBase
|
||||||
hostedDomain string
|
hostedDomain string
|
||||||
apiUrl 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) {
|
func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
|
||||||
var data struct {
|
data, errToken := s.extractFromToken(ctx, client, token)
|
||||||
Id string `json:"id"`
|
if errToken != nil {
|
||||||
Name string `json:"name"`
|
return nil, errToken
|
||||||
Email string `json:"email"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
response, err := s.httpGet(ctx, client, s.apiUrl)
|
||||||
if err != nil {
|
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 := json.Unmarshal(response.Body, &data); err != nil {
|
||||||
if err != nil {
|
return nil, fmt.Errorf("error unmarshalling user info: %s", err)
|
||||||
return nil, fmt.Errorf("Error getting user info: %s", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &BasicUserInfo{
|
return &data, nil
|
||||||
Id: data.Id,
|
|
||||||
Name: data.Name,
|
|
||||||
Email: data.Email,
|
|
||||||
Login: data.Email,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SocialGoogle) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
|
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...)
|
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
pkg/login/social/google_oauth_test.go
Normal file
504
pkg/login/social/google_oauth_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -37,6 +37,7 @@ type SocialService struct {
|
|||||||
|
|
||||||
socialMap map[string]SocialConnector
|
socialMap map[string]SocialConnector
|
||||||
oAuthProvider map[string]*OAuthInfo
|
oAuthProvider map[string]*OAuthInfo
|
||||||
|
log log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
type OAuthInfo struct {
|
type OAuthInfo struct {
|
||||||
@ -78,6 +79,7 @@ func ProvideService(cfg *setting.Cfg,
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
oAuthProvider: make(map[string]*OAuthInfo),
|
oAuthProvider: make(map[string]*OAuthInfo),
|
||||||
socialMap: make(map[string]SocialConnector),
|
socialMap: make(map[string]SocialConnector),
|
||||||
|
log: log.New("login.social"),
|
||||||
}
|
}
|
||||||
|
|
||||||
usageStats.RegisterMetricsFunc(ss.getUsageStats)
|
usageStats.RegisterMetricsFunc(ss.getUsageStats)
|
||||||
@ -138,7 +140,7 @@ func ProvideService(cfg *setting.Cfg,
|
|||||||
case "autodetect", "":
|
case "autodetect", "":
|
||||||
authStyle = oauth2.AuthStyleAutoDetect
|
authStyle = oauth2.AuthStyleAutoDetect
|
||||||
default:
|
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
|
authStyle = oauth2.AuthStyleAutoDetect
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,6 +179,9 @@ func ProvideService(cfg *setting.Cfg,
|
|||||||
|
|
||||||
// Google.
|
// Google.
|
||||||
if name == "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{
|
ss.socialMap["google"] = &SocialGoogle{
|
||||||
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
||||||
hostedDomain: info.HostedDomain,
|
hostedDomain: info.HostedDomain,
|
||||||
@ -475,7 +480,7 @@ func (ss *SocialService) GetOAuthHttpClient(name string) (*http.Client, error) {
|
|||||||
if info.TlsClientCert != "" || info.TlsClientKey != "" {
|
if info.TlsClientCert != "" || info.TlsClientKey != "" {
|
||||||
cert, err := tls.LoadX509KeyPair(info.TlsClientCert, info.TlsClientKey)
|
cert, err := tls.LoadX509KeyPair(info.TlsClientCert, info.TlsClientKey)
|
||||||
if err != nil {
|
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)
|
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 != "" {
|
if info.TlsClientCa != "" {
|
||||||
caCert, err := os.ReadFile(info.TlsClientCa)
|
caCert, err := os.ReadFile(info.TlsClientCa)
|
||||||
if err != nil {
|
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)
|
return nil, fmt.Errorf("failed to setup TlsClientCa: %w", err)
|
||||||
}
|
}
|
||||||
caCertPool := x509.NewCertPool()
|
caCertPool := x509.NewCertPool()
|
||||||
|
Loading…
Reference in New Issue
Block a user