Browse Source

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>
pull/3537/head
MichaelKo 2 years ago committed by GitHub
parent
commit
b0575946b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 77
      connector/google/google.go
  2. 98
      connector/google/google_test.go
  3. 2
      go.mod
  4. 4
      go.sum

77
connector/google/google.go

@ -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)

98
connector/google/google_test.go

@ -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 {

2
go.mod

@ -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

4
go.sum

@ -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=

Loading…
Cancel
Save