Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Google: Implement groups fetch by default service account from metadata (support for GKE workload identity) #2989

Merged
merged 10 commits into from
May 29, 2024
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 @@ -94,8 +96,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 @@ -350,25 +351,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 @@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"

"github.com/sirupsen/logrus"
Expand Down Expand Up @@ -291,3 +292,100 @@ 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)
}
})
}
}
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.20

require (
cloud.google.com/go/compute/metadata v0.2.3
entgo.io/ent v0.12.3
github.com/AppsFlyer/go-sundheit v0.5.0
github.com/Masterminds/semver v1.5.0
Expand Down Expand Up @@ -43,7 +44,6 @@ require (
require (
ariga.io/atlas v0.10.2-0.20230427182402-87a07dfb83bf // indirect
cloud.google.com/go/compute v1.19.3 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.0 // indirect
Expand Down