Skip to content

Commit

Permalink
Google: Implement groups fetch by default service account from metada…
Browse files Browse the repository at this point in the history
…ta (support for GKE workload identity) (#2989)

Signed-off-by: Viacheslav Sychov <[email protected]>
Signed-off-by: Maksim Nabokikh <[email protected]>
Co-authored-by: Maksim Nabokikh <[email protected]>
  • Loading branch information
vsychov and nabokihms authored May 29, 2024
1 parent 088c3e5 commit b057594
Show file tree
Hide file tree
Showing 4 changed files with 171 additions and 10 deletions.
77 changes: 68 additions & 9 deletions connector/google/google.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
98 changes: 98 additions & 0 deletions connector/google/google_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"

"github.com/sirupsen/logrus"
Expand Down Expand Up @@ -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("[email protected]")
})
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": "[email protected]"},
})
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": "[email protected]"},
})
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: "[email protected]",
expectedErr: "",
},
"wrong_user_request": {
userKey: "[email protected]",
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 {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down

0 comments on commit b057594

Please sign in to comment.