mirror of
https://github.com/netbirdio/dex.git
synced 2026-05-22 18:43:53 -07:00
Google: Implement groups fetch by default service account from metadata (support for GKE workload identity) (#2989)
Signed-off-by: Viacheslav Sychov <viacheslav.sychov@gmail.com> Signed-off-by: Maksim Nabokikh <maksim.nabokikh@flant.com> Co-authored-by: Maksim Nabokikh <maksim.nabokikh@flant.com>
This commit is contained in:
co-authored by
Maksim Nabokikh
parent
088c3e52ad
commit
b0575946b1
@@ -10,11 +10,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/compute/metadata"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
admin "google.golang.org/api/admin/directory/v1"
|
||||
"google.golang.org/api/impersonate"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
@@ -98,8 +100,7 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e
|
||||
return nil, fmt.Errorf("directory service requires the domainToAdminEmail option to be configured")
|
||||
}
|
||||
|
||||
// Fixing a regression caused by default config fallback: https://github.com/dexidp/dex/issues/2699
|
||||
if (c.ServiceAccountFilePath != "" && len(c.DomainToAdminEmail) > 0) || slices.Contains(scopes, "groups") {
|
||||
if (len(c.DomainToAdminEmail) > 0) || slices.Contains(scopes, "groups") {
|
||||
for domain, adminEmail := range c.DomainToAdminEmail {
|
||||
srv, err := createDirectoryService(c.ServiceAccountFilePath, adminEmail, logger)
|
||||
if err != nil {
|
||||
@@ -362,25 +363,83 @@ func (c *googleConnector) extractDomainFromEmail(email string) string {
|
||||
return wildcardDomainToAdminEmail
|
||||
}
|
||||
|
||||
// getCredentialsFromFilePath reads and returns the service account credentials from the file at the provided path.
|
||||
// If an error occurs during the read, it is returned.
|
||||
func getCredentialsFromFilePath(serviceAccountFilePath string) ([]byte, error) {
|
||||
jsonCredentials, err := os.ReadFile(serviceAccountFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading credentials from file: %v", err)
|
||||
}
|
||||
return jsonCredentials, nil
|
||||
}
|
||||
|
||||
// getCredentialsFromDefault retrieves the application's default credentials.
|
||||
// If the default credential is empty, it attempts to create a new service with metadata credentials.
|
||||
// If successful, it returns the service and nil error.
|
||||
// If unsuccessful, it returns the error and a nil service.
|
||||
func getCredentialsFromDefault(ctx context.Context, email string, logger log.Logger) ([]byte, *admin.Service, error) {
|
||||
credential, err := google.FindDefaultCredentials(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to fetch application default credentials: %w", err)
|
||||
}
|
||||
|
||||
if credential.JSON == nil {
|
||||
logger.Info("JSON is empty, using flow for GCE")
|
||||
service, err := createServiceWithMetadataServer(ctx, email, logger)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, service, nil
|
||||
}
|
||||
|
||||
return credential.JSON, nil, nil
|
||||
}
|
||||
|
||||
// createServiceWithMetadataServer creates a new service using metadata server.
|
||||
// If an error occurs during the process, it is returned along with a nil service.
|
||||
func createServiceWithMetadataServer(ctx context.Context, adminEmail string, logger log.Logger) (*admin.Service, error) {
|
||||
serviceAccountEmail, err := metadata.Email("default")
|
||||
logger.Infof("discovered serviceAccountEmail: %s", serviceAccountEmail)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get service account email from metadata server: %v", err)
|
||||
}
|
||||
|
||||
config := impersonate.CredentialsConfig{
|
||||
TargetPrincipal: serviceAccountEmail,
|
||||
Scopes: []string{admin.AdminDirectoryGroupReadonlyScope},
|
||||
Lifetime: 0,
|
||||
Subject: adminEmail,
|
||||
}
|
||||
|
||||
tokenSource, err := impersonate.CredentialsTokenSource(ctx, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to impersonate with %s, error: %v", adminEmail, err)
|
||||
}
|
||||
|
||||
return admin.NewService(ctx, option.WithHTTPClient(oauth2.NewClient(ctx, tokenSource)))
|
||||
}
|
||||
|
||||
// createDirectoryService sets up super user impersonation and creates an admin client for calling
|
||||
// the google admin api. If no serviceAccountFilePath is defined, the application default credential
|
||||
// is used.
|
||||
func createDirectoryService(serviceAccountFilePath, email string, logger log.Logger) (*admin.Service, error) {
|
||||
func createDirectoryService(serviceAccountFilePath, email string, logger log.Logger) (service *admin.Service, err error) {
|
||||
var jsonCredentials []byte
|
||||
var err error
|
||||
|
||||
ctx := context.Background()
|
||||
if serviceAccountFilePath == "" {
|
||||
logger.Warn("the application default credential is used since the service account file path is not used")
|
||||
credential, err := google.FindDefaultCredentials(ctx)
|
||||
jsonCredentials, service, err = getCredentialsFromDefault(ctx, email, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch application default credentials: %w", err)
|
||||
return
|
||||
}
|
||||
if service != nil {
|
||||
return
|
||||
}
|
||||
jsonCredentials = credential.JSON
|
||||
} else {
|
||||
jsonCredentials, err = os.ReadFile(serviceAccountFilePath)
|
||||
jsonCredentials, err = getCredentialsFromFilePath(serviceAccountFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading credentials from file: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
config, err := google.JWTConfigFromJSON(jsonCredentials, admin.AdminDirectoryGroupReadonlyScope)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
@@ -295,6 +296,103 @@ func TestDomainToAdminEmailConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
var gceMetadataFlags = map[string]bool{
|
||||
"failOnEmailRequest": false,
|
||||
}
|
||||
|
||||
func mockGCEMetadataServer() *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/computeMetadata/v1/instance/service-accounts/default/email", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if gceMetadataFlags["failOnEmailRequest"] {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
json.NewEncoder(w).Encode("my-service-account@example-project.iam.gserviceaccount.com")
|
||||
})
|
||||
mux.HandleFunc("/computeMetadata/v1/instance/service-accounts/default/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresInSec int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}{
|
||||
AccessToken: "my-example.token",
|
||||
ExpiresInSec: 3600,
|
||||
TokenType: "Bearer",
|
||||
})
|
||||
})
|
||||
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestGCEWorkloadIdentity(t *testing.T) {
|
||||
ts := testSetup()
|
||||
defer ts.Close()
|
||||
|
||||
metadataServer := mockGCEMetadataServer()
|
||||
defer metadataServer.Close()
|
||||
metadataServerHost := strings.Replace(metadataServer.URL, "http://", "", 1)
|
||||
|
||||
os.Setenv("GCE_METADATA_HOST", metadataServerHost)
|
||||
os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "")
|
||||
os.Setenv("HOME", "/tmp")
|
||||
|
||||
gceMetadataFlags["failOnEmailRequest"] = true
|
||||
_, err := newConnector(&Config{
|
||||
ClientID: "testClient",
|
||||
ClientSecret: "testSecret",
|
||||
RedirectURI: ts.URL + "/callback",
|
||||
Scopes: []string{"openid", "groups"},
|
||||
DomainToAdminEmail: map[string]string{"dexidp.com": "admin@dexidp.com"},
|
||||
})
|
||||
assert.Error(t, err)
|
||||
|
||||
gceMetadataFlags["failOnEmailRequest"] = false
|
||||
conn, err := newConnector(&Config{
|
||||
ClientID: "testClient",
|
||||
ClientSecret: "testSecret",
|
||||
RedirectURI: ts.URL + "/callback",
|
||||
Scopes: []string{"openid", "groups"},
|
||||
DomainToAdminEmail: map[string]string{"dexidp.com": "admin@dexidp.com"},
|
||||
})
|
||||
assert.Nil(t, err)
|
||||
|
||||
conn.adminSrv["dexidp.com"], err = admin.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(ts.URL))
|
||||
assert.Nil(t, err)
|
||||
type testCase struct {
|
||||
userKey string
|
||||
expectedErr string
|
||||
}
|
||||
|
||||
for name, testCase := range map[string]testCase{
|
||||
"correct_user_request": {
|
||||
userKey: "user_1@dexidp.com",
|
||||
expectedErr: "",
|
||||
},
|
||||
"wrong_user_request": {
|
||||
userKey: "user_1@foo.bar",
|
||||
expectedErr: "unable to find super admin email",
|
||||
},
|
||||
"wrong_connector_response": {
|
||||
userKey: "user_1_foo.bar",
|
||||
expectedErr: "unable to find super admin email",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
lookup := make(map[string]struct{})
|
||||
|
||||
_, err := conn.getGroups(testCase.userKey, true, lookup)
|
||||
if testCase.expectedErr != "" {
|
||||
assert.ErrorContains(err, testCase.expectedErr)
|
||||
} else {
|
||||
assert.Nil(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptTypeConfig(t *testing.T) {
|
||||
promptTypeLogin := "login"
|
||||
cases := []struct {
|
||||
|
||||
@@ -3,6 +3,7 @@ module github.com/dexidp/dex
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0
|
||||
entgo.io/ent v0.13.1
|
||||
github.com/AppsFlyer/go-sundheit v0.5.0
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
@@ -45,7 +46,6 @@ require (
|
||||
ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 // indirect
|
||||
cloud.google.com/go/auth v0.4.1 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
|
||||
@@ -231,6 +231,8 @@ go.etcd.io/etcd/client/v3 v3.5.13 h1:o0fHTNJLeO0MyVbc7I3fsCf6nrOqn5d+diSarKnB2js
|
||||
go.etcd.io/etcd/client/v3 v3.5.13/go.mod h1:cqiAeY8b5DEEcpxvgWKsbLIWNM/8Wy2xJSDMtioMcoI=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
|
||||
@@ -327,6 +329,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
|
||||
Reference in New Issue
Block a user