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

Implement Application Default Credentials for the google connector #2530

Merged
merged 11 commits into from
Sep 7, 2022
2 changes: 1 addition & 1 deletion connector/atlassiancrowd/atlassiancrowd.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ type crowdAuthenticationError struct {
}

// Open returns a strategy for logging in through Atlassian Crowd
func (c *Config) Open(_ string, logger log.Logger) (connector.Connector, error) {
func (c *Config) Open(_ string, logger log.Logger, opts ...interface{}) (connector.Connector, error) {
if c.BaseURL == "" {
return nil, fmt.Errorf("crowd: no baseURL provided for crowd connector")
}
Expand Down
2 changes: 1 addition & 1 deletion connector/authproxy/authproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ type Config struct {
}

// Open returns an authentication strategy which requires no user interaction.
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
func (c *Config) Open(id string, logger log.Logger, opts ...interface{}) (connector.Connector, error) {
userHeader := c.UserHeader
if userHeader == "" {
userHeader = "X-Remote-User"
Expand Down
2 changes: 1 addition & 1 deletion connector/bitbucketcloud/bitbucketcloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ type Config struct {
}

// Open returns a strategy for logging in through Bitbucket.
func (c *Config) Open(_ string, logger log.Logger) (connector.Connector, error) {
func (c *Config) Open(_ string, logger log.Logger, opts ...interface{}) (connector.Connector, error) {
b := bitbucketConnector{
redirectURI: c.RedirectURI,
teams: c.Teams,
Expand Down
2 changes: 1 addition & 1 deletion connector/gitea/gitea.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ type giteaUser struct {
}

// Open returns a strategy for logging in through Gitea
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
func (c *Config) Open(id string, logger log.Logger, opts ...interface{}) (connector.Connector, error) {
if c.BaseURL == "" {
c.BaseURL = "https://gitea.com"
}
Expand Down
2 changes: 1 addition & 1 deletion connector/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ type Org struct {
}

// Open returns a strategy for logging in through GitHub.
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
func (c *Config) Open(id string, logger log.Logger, opts ...interface{}) (connector.Connector, error) {
if c.Org != "" {
// Return error if both 'org' and 'orgs' fields are used.
if len(c.Orgs) > 0 {
Expand Down
2 changes: 1 addition & 1 deletion connector/gitlab/gitlab.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ type gitlabUser struct {
}

// Open returns a strategy for logging in through GitLab.
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
func (c *Config) Open(id string, logger log.Logger, opts ...interface{}) (connector.Connector, error) {
nabokihms marked this conversation as resolved.
Show resolved Hide resolved
if c.BaseURL == "" {
c.BaseURL = "https://gitlab.com"
}
Expand Down
64 changes: 37 additions & 27 deletions connector/google/google.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ type Config struct {
}

// Open returns a connector which can be used to login users through Google.
func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, err error) {
func (c *Config) Open(id string, logger log.Logger, opts ...interface{}) (conn connector.Connector, err error) {
ctx, cancel := context.WithCancel(context.Background())

provider, err := oidc.NewProvider(ctx, issuerURL)
Expand All @@ -71,7 +71,17 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e
scopes = append(scopes, "profile", "email")
}

srv, err := createDirectoryService(c.ServiceAccountFilePath, c.AdminEmail)
var ok bool
clientOpts := make([]option.ClientOption, len(opts))
for i, opt := range opts {
clientOpts[i], ok = opt.(option.ClientOption)
if !ok {
cancel()
return nil, fmt.Errorf("options passed to google connector cannot be cast into option.ClientOptions")
}
}

srv, err := createDirectoryService(c.ServiceAccountFilePath, c.AdminEmail, logger, clientOpts...)
if err != nil {
cancel()
return nil, fmt.Errorf("could not create directory service: %v", err)
Expand Down Expand Up @@ -279,37 +289,37 @@ func (c *googleConnector) getGroups(email string, fetchTransitiveGroupMembership
return uniqueGroups(userGroups), nil
}

// createDirectoryService loads a google service account credentials file,
// sets up super user impersonation and creates an admin client for calling
// the google admin api
func createDirectoryService(serviceAccountFilePath string, email string) (*admin.Service, error) {
if serviceAccountFilePath == "" && email == "" {
return nil, nil
}
if serviceAccountFilePath == "" || email == "" {
return nil, fmt.Errorf("directory service requires both serviceAccountFilePath and adminEmail")
}
jsonCredentials, err := os.ReadFile(serviceAccountFilePath)
if err != nil {
return nil, fmt.Errorf("error reading credentials from file: %v", err)
// 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, opts ...option.ClientOption) (*admin.Service, error) {
if email == "" {
return nil, fmt.Errorf("directory service requires adminEmail")
}

config, err := google.JWTConfigFromJSON(jsonCredentials, admin.AdminDirectoryGroupReadonlyScope)
if err != nil {
return nil, fmt.Errorf("unable to parse client secret file to config: %v", err)
}

// Impersonate an admin. This is mandatory for the admin APIs.
config.Subject = email
var jsonCredentials []byte
var err error

ctx := context.Background()
client := config.Client(ctx)

srv, err := admin.NewService(ctx, option.WithHTTPClient(client))
if serviceAccountFilePath == "" {
logger.Warn("the application default credential is used since the service account file path is not used")
credential, err := google.FindDefaultCredentials(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch application default credentials: %w", err)
}
jsonCredentials = credential.JSON
} else {
jsonCredentials, err = os.ReadFile(serviceAccountFilePath)
nabokihms marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, fmt.Errorf("error reading credentials from file: %v", err)
}
}
config, err := google.JWTConfigFromJSON(jsonCredentials, admin.AdminDirectoryGroupReadonlyScope)
if err != nil {
return nil, fmt.Errorf("unable to create directory service %v", err)
return nil, fmt.Errorf("unable to parse credentials to config: %v", err)
}
return srv, nil
config.Subject = email
return admin.NewService(ctx, option.WithHTTPClient(config.Client(ctx)))
}

// uniqueGroups returns the unique groups of a slice
Expand Down
146 changes: 146 additions & 0 deletions connector/google/google_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package google

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"

"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"google.golang.org/api/option"
)

func testSetup(t *testing.T) *httptest.Server {
mux := http.NewServeMux()
// TODO: mock calls
// mux.HandleFunc("/admin/directory/v1/groups", func(w http.ResponseWriter, r *http.Request) {
// w.Header().Add("Content-Type", "application/json")
// json.NewEncoder(w).Encode(&admin.Groups{
// Groups: []*admin.Group{},
// })
// })
return httptest.NewServer(mux)
}

func newConnector(config *Config, serverURL string) (*googleConnector, error) {
log := logrus.New()
conn, err := config.Open("id", log, option.WithEndpoint(serverURL))
nabokihms marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, err
}

googleConn, ok := conn.(*googleConnector)
if !ok {
return nil, fmt.Errorf("failed to convert to googleConnector")
}
return googleConn, nil
}

func tempServiceAccountKey() (string, error) {
fd, err := os.CreateTemp("", "google_service_account_key")
if err != nil {
return "", err
}
defer fd.Close()
err = json.NewEncoder(fd).Encode(map[string]string{
"type": "service_account",
"project_id": "sample-project",
"private_key_id": "sample-key-id",
"private_key": "-----BEGIN PRIVATE KEY-----\nsample-key\n-----END PRIVATE KEY-----\n",
"client_id": "sample-client-id",
"client_x509_cert_url": "localhost",
})
return fd.Name(), err
}

func TestOpen(t *testing.T) {
ts := testSetup(t)
defer ts.Close()

type testCase struct {
config *Config
expectedErr string

// string to set in GOOGLE_APPLICATION_CREDENTIALS. As local development environments can
// already contain ADC, test cases will be built uppon this setting this env variable
adc string
}

serviceAccountFilePath, err := tempServiceAccountKey()
assert.Nil(t, err)

for name, reference := range map[string]testCase{
"missing_admin_email": {
config: &Config{
ClientID: "testClient",
ClientSecret: "testSecret",
RedirectURI: ts.URL + "/callback",
Scopes: []string{"openid", "groups"},
},
expectedErr: "requires adminEmail",
},
"service_account_key_not_found": {
config: &Config{
ClientID: "testClient",
ClientSecret: "testSecret",
RedirectURI: ts.URL + "/callback",
Scopes: []string{"openid", "groups"},
AdminEmail: "[email protected]",
ServiceAccountFilePath: "not_found.json",
},
expectedErr: "error reading credentials",
},
"service_account_key_valid": {
config: &Config{
ClientID: "testClient",
ClientSecret: "testSecret",
RedirectURI: ts.URL + "/callback",
Scopes: []string{"openid", "groups"},
AdminEmail: "[email protected]",
ServiceAccountFilePath: serviceAccountFilePath,
},
expectedErr: "",
},
"adc": {
config: &Config{
ClientID: "testClient",
ClientSecret: "testSecret",
RedirectURI: ts.URL + "/callback",
Scopes: []string{"openid", "groups"},
AdminEmail: "[email protected]",
},
adc: serviceAccountFilePath,
expectedErr: "",
},
"adc_priority": {
config: &Config{
ClientID: "testClient",
ClientSecret: "testSecret",
RedirectURI: ts.URL + "/callback",
Scopes: []string{"openid", "groups"},
AdminEmail: "[email protected]",
ServiceAccountFilePath: serviceAccountFilePath,
},
adc: "/dev/null",
expectedErr: "",
},
} {
reference := reference
t.Run(name, func(t *testing.T) {
assert := assert.New(t)

os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", reference.adc)
conn, err := newConnector(reference.config, ts.URL)

if reference.expectedErr == "" {
assert.Nil(err)
assert.NotNil(conn)
} else {
assert.ErrorContains(err, reference.expectedErr)
}
})
}
}
2 changes: 1 addition & 1 deletion connector/keystone/keystone.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ var (
)

// Open returns an authentication strategy using Keystone.
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
func (c *Config) Open(id string, logger log.Logger, opts ...interface{}) (connector.Connector, error) {
return &conn{
c.Domain,
c.Host,
Expand Down
2 changes: 1 addition & 1 deletion connector/ldap/ldap.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ func userMatchers(c *Config, logger log.Logger) []UserMatcher {
}

// Open returns an authentication strategy using LDAP.
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
func (c *Config) Open(id string, logger log.Logger, opts ...interface{}) (connector.Connector, error) {
conn, err := c.OpenConnector(logger)
if err != nil {
return nil, err
Expand Down
2 changes: 1 addition & 1 deletion connector/linkedin/linkedin.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ type Config struct {
}

// Open returns a strategy for logging in through LinkedIn
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
func (c *Config) Open(id string, logger log.Logger, opts ...interface{}) (connector.Connector, error) {
return &linkedInConnector{
oauth2Config: &oauth2.Config{
ClientID: c.ClientID,
Expand Down
2 changes: 1 addition & 1 deletion connector/microsoft/microsoft.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ type Config struct {
}

// Open returns a strategy for logging in through Microsoft.
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
func (c *Config) Open(id string, logger log.Logger, opts ...interface{}) (connector.Connector, error) {
m := microsoftConnector{
apiURL: "https://login.microsoftonline.com",
graphURL: "https://graph.microsoft.com",
Expand Down
4 changes: 2 additions & 2 deletions connector/mock/connectortest.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func (m *Callback) Refresh(ctx context.Context, s connector.Scopes, identity con
type CallbackConfig struct{}

// Open returns an authentication strategy which requires no user interaction.
func (c *CallbackConfig) Open(id string, logger log.Logger) (connector.Connector, error) {
func (c *CallbackConfig) Open(id string, logger log.Logger, opts ...interface{}) (connector.Connector, error) {
return NewCallbackConnector(logger), nil
}

Expand All @@ -82,7 +82,7 @@ type PasswordConfig struct {
}

// Open returns an authentication strategy which prompts for a predefined username and password.
func (c *PasswordConfig) Open(id string, logger log.Logger) (connector.Connector, error) {
func (c *PasswordConfig) Open(id string, logger log.Logger, opts ...interface{}) (connector.Connector, error) {
if c.Username == "" {
return nil, errors.New("no username supplied")
}
Expand Down
2 changes: 1 addition & 1 deletion connector/oauth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ type Config struct {
} `json:"claimMapping"`
}

func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
func (c *Config) Open(id string, logger log.Logger, opts ...interface{}) (connector.Connector, error) {
var err error

userIDKey := c.UserIDKey
Expand Down
2 changes: 1 addition & 1 deletion connector/oidc/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ func knownBrokenAuthHeaderProvider(issuerURL string) bool {

// Open returns a connector which can be used to login users through an upstream
// OpenID Connect provider.
func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, err error) {
func (c *Config) Open(id string, logger log.Logger, opts ...interface{}) (conn connector.Connector, err error) {
ctx, cancel := context.WithCancel(context.Background())

provider, err := oidc.NewProvider(ctx, c.Issuer)
Expand Down
2 changes: 1 addition & 1 deletion connector/openshift/openshift.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ type user struct {

// Open returns a connector which can be used to login users through an upstream
// OpenShift OAuth2 provider.
func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, err error) {
func (c *Config) Open(id string, logger log.Logger, opts ...interface{}) (conn connector.Connector, err error) {
httpClient, err := newHTTPClient(c.InsecureCA, c.RootCA)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
Expand Down
2 changes: 1 addition & 1 deletion connector/saml/saml.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ func (c certStore) Certificates() (roots []*x509.Certificate, err error) {

// Open validates the config and returns a connector. It does not actually
// validate connectivity with the provider.
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
func (c *Config) Open(id string, logger log.Logger, opts ...interface{}) (connector.Connector, error) {
return c.openConnector(logger)
}

Expand Down
2 changes: 1 addition & 1 deletion server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ func (s *Server) startGarbageCollection(ctx context.Context, frequency time.Dura

// ConnectorConfig is a configuration that can open a connector.
type ConnectorConfig interface {
Open(id string, logger log.Logger) (connector.Connector, error)
Open(id string, logger log.Logger, opts ...interface{}) (connector.Connector, error)
}

// ConnectorsConfig variable provides an easy way to return a config struct
Expand Down