diff --git a/CHANGELOG.md b/CHANGELOG.md index c3553448..e28156da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - [#1988](https://github.com/oauth2-proxy/oauth2-proxy/pull/1988) Ensure sign-in page background is uniform throughout the page - [#2013](https://github.com/oauth2-proxy/oauth2-proxy/pull/2013) Upgrade alpine to version 3.17.2 and library dependencies (@miguelborges99) - [#2047](https://github.com/oauth2-proxy/oauth2-proxy/pull/2047) CVE-2022-41717: DoS in Go net/http may lead to DoS (@miguelborges99) +- [#2126](https://github.com/oauth2-proxy/oauth2-proxy/pull/2126) Added support for GKE Workload Identity (@kvanzuijlen) - [#1921](https://github.com/oauth2-proxy/oauth2-proxy/pull/1921) Check jsonpath syntax before interpretation - [#2025](https://github.com/oauth2-proxy/oauth2-proxy/pull/2025) Embed static stylesheets and dependencies diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md index 84dac78d..ad2f7682 100644 --- a/docs/docs/configuration/alpha_config.md +++ b/docs/docs/configuration/alpha_config.md @@ -236,6 +236,7 @@ Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". | `group` | _[]string_ | Groups sets restrict logins to members of this google group | | `adminEmail` | _string_ | AdminEmail is the google admin to impersonate for api calls | | `serviceAccountJson` | _string_ | ServiceAccountJSON is the path to the service account json credentials | +| `useApplicationDefaultCredentials` | _bool_ | UseApplicationDefaultCredentials is a boolean whether to use Application Default Credentials instead of a ServiceAccountJSON | ### Header diff --git a/docs/docs/configuration/auth.md b/docs/docs/configuration/auth.md index d219a140..cfcad1d1 100644 --- a/docs/docs/configuration/auth.md +++ b/docs/docs/configuration/auth.md @@ -50,12 +50,22 @@ It's recommended to refresh sessions on a short interval (1h) with `cookie-refre #### Restrict auth to specific Google groups on your domain. (optional) -1. Create a service account: https://developers.google.com/identity/protocols/OAuth2ServiceAccount and make sure to download the json file. +1. Create a [service account](https://developers.google.com/identity/protocols/OAuth2ServiceAccount) and download the json +file if you're not using [Application Default Credentials / Workload Identity / Workload Identity Federation (recommended)](#using-application-default-credentials-adc--workload-identity--workload-identity-federation-recommended). 2. Make note of the Client ID for a future step. 3. Under "APIs & Auth", choose APIs. 4. Click on Admin SDK and then Enable API. 5. Follow the steps on https://developers.google.com/admin-sdk/directory/v1/guides/delegation#delegate_domain-wide_authority_to_your_service_account and give the client id from step 2 the following oauth scopes: + +##### Using Application Default Credentials (ADC) / Workload Identity / Workload Identity Federation (recommended) +oauth2-proxy can make use of [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials). +When deployed within GCP, this means that it can automatically use the service account attached to the resource. When deployed to GKE, ADC +can be leveraged through a feature called Workload Identity. Follow Google's [guide](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) +to set up Workload Identity. + +When deployed outside of GCP, [Workload Identity Federation](https://cloud.google.com/docs/authentication/provide-credentials-adc#wlif) might be an option. + ``` https://www.googleapis.com/auth/admin.directory.group.readonly https://www.googleapis.com/auth/admin.directory.user.readonly diff --git a/docs/docs/configuration/overview.md b/docs/docs/configuration/overview.md index f139edd7..e7fcc244 100644 --- a/docs/docs/configuration/overview.md +++ b/docs/docs/configuration/overview.md @@ -120,6 +120,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/ | `--google-admin-email` | string | the google admin to impersonate for api calls | | | `--google-group` | string | restrict logins to members of this google group (may be given multiple times). | | | `--google-service-account-json` | string | the path to the service account json credentials | | +| `--google-use-application-default-credentials` | bool | use application default credentials instead of service account json (i.e. GKE Workload Identity) | | | `--htpasswd-file` | string | additionally authenticate against a htpasswd file. Entries must be created with `htpasswd -B` for bcrypt encryption | | | `--htpasswd-user-group` | string \| list | the groups to be set on sessions for htpasswd users | | | `--http-address` | string | `[http://]:` or `unix://` to listen on for HTTP clients. Square brackets are required for ipv6 address, e.g. `http://[::1]:4180` | `"127.0.0.1:4180"` | diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index 3de9786b..db1b0bf7 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -70,6 +70,7 @@ func NewLegacyFlagSet() *pflag.FlagSet { flagSet.AddFlagSet(legacyHeadersFlagSet()) flagSet.AddFlagSet(legacyServerFlagset()) flagSet.AddFlagSet(legacyProviderFlagSet()) + flagSet.AddFlagSet(legacyGoogleFlagSet()) return flagSet } @@ -481,21 +482,22 @@ type LegacyProvider struct { ClientSecret string `flag:"client-secret" cfg:"client_secret"` ClientSecretFile string `flag:"client-secret-file" cfg:"client_secret_file"` - KeycloakGroups []string `flag:"keycloak-group" cfg:"keycloak_groups"` - AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"` - AzureGraphGroupField string `flag:"azure-graph-group-field" cfg:"azure_graph_group_field"` - BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"` - BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"` - GitHubOrg string `flag:"github-org" cfg:"github_org"` - GitHubTeam string `flag:"github-team" cfg:"github_team"` - GitHubRepo string `flag:"github-repo" cfg:"github_repo"` - GitHubToken string `flag:"github-token" cfg:"github_token"` - GitHubUsers []string `flag:"github-user" cfg:"github_users"` - GitLabGroup []string `flag:"gitlab-group" cfg:"gitlab_groups"` - GitLabProjects []string `flag:"gitlab-project" cfg:"gitlab_projects"` - GoogleGroups []string `flag:"google-group" cfg:"google_group"` - GoogleAdminEmail string `flag:"google-admin-email" cfg:"google_admin_email"` - GoogleServiceAccountJSON string `flag:"google-service-account-json" cfg:"google_service_account_json"` + KeycloakGroups []string `flag:"keycloak-group" cfg:"keycloak_groups"` + AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"` + AzureGraphGroupField string `flag:"azure-graph-group-field" cfg:"azure_graph_group_field"` + BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"` + BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"` + GitHubOrg string `flag:"github-org" cfg:"github_org"` + GitHubTeam string `flag:"github-team" cfg:"github_team"` + GitHubRepo string `flag:"github-repo" cfg:"github_repo"` + GitHubToken string `flag:"github-token" cfg:"github_token"` + GitHubUsers []string `flag:"github-user" cfg:"github_users"` + GitLabGroup []string `flag:"gitlab-group" cfg:"gitlab_groups"` + GitLabProjects []string `flag:"gitlab-project" cfg:"gitlab_projects"` + GoogleGroups []string `flag:"google-group" cfg:"google_group"` + GoogleAdminEmail string `flag:"google-admin-email" cfg:"google_admin_email"` + GoogleServiceAccountJSON string `flag:"google-service-account-json" cfg:"google_service_account_json"` + GoogleUseApplicationDefaultCredentials bool `flag:"google-use-application-default-credentials" cfg:"google_use_application_default_credentials"` // These options allow for other providers besides Google, with // potential overrides. @@ -549,9 +551,6 @@ func legacyProviderFlagSet() *pflag.FlagSet { flagSet.StringSlice("github-user", []string{}, "allow users with these usernames to login even if they do not belong to the specified org and team or collaborators (may be given multiple times)") flagSet.StringSlice("gitlab-group", []string{}, "restrict logins to members of this group (may be given multiple times)") flagSet.StringSlice("gitlab-project", []string{}, "restrict logins to members of this project (may be given multiple times) (eg `group/project=accesslevel`). Access level should be a value matching Gitlab access levels (see https://docs.gitlab.com/ee/api/members.html#valid-access-levels), defaulted to 20 if absent") - flagSet.StringSlice("google-group", []string{}, "restrict logins to members of this google group (may be given multiple times).") - flagSet.String("google-admin-email", "", "the google admin to impersonate for api calls") - flagSet.String("google-service-account-json", "", "the path to the service account json credentials") flagSet.String("client-id", "", "the OAuth Client ID: ie: \"123456.apps.googleusercontent.com\"") flagSet.String("client-secret", "", "the OAuth Client Secret") flagSet.String("client-secret-file", "", "the file with OAuth Client Secret") @@ -592,6 +591,17 @@ func legacyProviderFlagSet() *pflag.FlagSet { return flagSet } +func legacyGoogleFlagSet() *pflag.FlagSet { + flagSet := pflag.NewFlagSet("google", pflag.ExitOnError) + + flagSet.StringSlice("google-group", []string{}, "restrict logins to members of this google group (may be given multiple times).") + flagSet.String("google-admin-email", "", "the google admin to impersonate for api calls") + flagSet.String("google-service-account-json", "", "the path to the service account json credentials") + flagSet.String("google-use-application-default-credentials", "", "use application default credentials instead of service account json (i.e. GKE Workload Identity)") + + return flagSet +} + func (l LegacyServer) convert() (Server, Server) { appServer := Server{ BindAddress: l.HTTPAddress, @@ -718,9 +728,10 @@ func (l *LegacyProvider) convert() (Providers, error) { } case "google": provider.GoogleConfig = GoogleOptions{ - Groups: l.GoogleGroups, - AdminEmail: l.GoogleAdminEmail, - ServiceAccountJSON: l.GoogleServiceAccountJSON, + Groups: l.GoogleGroups, + AdminEmail: l.GoogleAdminEmail, + ServiceAccountJSON: l.GoogleServiceAccountJSON, + UseApplicationDefaultCredentials: l.GoogleUseApplicationDefaultCredentials, } } diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index b559e438..c9ec262f 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -189,6 +189,8 @@ type GoogleOptions struct { AdminEmail string `json:"adminEmail,omitempty"` // ServiceAccountJSON is the path to the service account json credentials ServiceAccountJSON string `json:"serviceAccountJson,omitempty"` + // UseApplicationDefaultCredentials is a boolean whether to use Application Default Credentials instead of a ServiceAccountJSON + UseApplicationDefaultCredentials bool `json:"useApplicationDefaultCredentials,omitempty"` } type OIDCOptions struct { diff --git a/pkg/validation/options_test.go b/pkg/validation/options_test.go index fa45221c..2d5e9560 100644 --- a/pkg/validation/options_test.go +++ b/pkg/validation/options_test.go @@ -63,7 +63,7 @@ func TestGoogleGroupOptions(t *testing.T) { expected := errorMsg([]string{ "missing setting: google-admin-email", - "missing setting: google-service-account-json"}) + "missing setting: google-service-account-json or google-use-application-default-credentials"}) assert.Equal(t, expected, err.Error()) } @@ -76,7 +76,7 @@ func TestGoogleGroupInvalidFile(t *testing.T) { assert.NotEqual(t, nil, err) expected := errorMsg([]string{ - "invalid Google credentials file: file_doesnt_exist.json", + "Google credentials file not found: file_doesnt_exist.json", }) assert.Equal(t, expected, err.Error()) } diff --git a/pkg/validation/providers.go b/pkg/validation/providers.go index cb84ad90..ecc2d06d 100644 --- a/pkg/validation/providers.go +++ b/pkg/validation/providers.go @@ -66,20 +66,32 @@ func validateProvider(provider options.Provider, providerIDs map[string]struct{} func validateGoogleConfig(provider options.Provider) []string { msgs := []string{} - if len(provider.GoogleConfig.Groups) > 0 || - provider.GoogleConfig.AdminEmail != "" || - provider.GoogleConfig.ServiceAccountJSON != "" { - if len(provider.GoogleConfig.Groups) < 1 { - msgs = append(msgs, "missing setting: google-group") - } - if provider.GoogleConfig.AdminEmail == "" { - msgs = append(msgs, "missing setting: google-admin-email") - } - if provider.GoogleConfig.ServiceAccountJSON == "" { - msgs = append(msgs, "missing setting: google-service-account-json") - } else if _, err := os.Stat(provider.GoogleConfig.ServiceAccountJSON); err != nil { - msgs = append(msgs, fmt.Sprintf("invalid Google credentials file: %s", provider.GoogleConfig.ServiceAccountJSON)) + + hasGoogleGroups := len(provider.GoogleConfig.Groups) >= 1 + hasAdminEmail := provider.GoogleConfig.AdminEmail != "" + hasSAJSON := provider.GoogleConfig.ServiceAccountJSON != "" + useADC := provider.GoogleConfig.UseApplicationDefaultCredentials + + if !hasGoogleGroups && !hasAdminEmail && !hasSAJSON && !useADC { + return msgs + } + + if !hasGoogleGroups { + msgs = append(msgs, "missing setting: google-group") + } + if !hasAdminEmail { + msgs = append(msgs, "missing setting: google-admin-email") + } + + _, err := os.Stat(provider.GoogleConfig.ServiceAccountJSON) + if !useADC { + if !hasSAJSON { + msgs = append(msgs, "missing setting: google-service-account-json or google-use-application-default-credentials") + } else if err != nil { + msgs = append(msgs, fmt.Sprintf("Google credentials file not found: %s", provider.GoogleConfig.ServiceAccountJSON)) } + } else if hasSAJSON { + msgs = append(msgs, "invalid setting: can't use both google-service-account-json and google-use-application-default-credentials") } return msgs diff --git a/providers/google.go b/providers/google.go index 62399f09..c0ae5baf 100644 --- a/providers/google.go +++ b/providers/google.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "net/http" "net/url" "os" "strings" @@ -17,6 +18,7 @@ import ( "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" + "golang.org/x/oauth2" "golang.org/x/oauth2/google" admin "google.golang.org/api/admin/directory/v1" "google.golang.org/api/googleapi" @@ -98,17 +100,13 @@ func NewGoogleProvider(p *ProviderData, opts options.GoogleOptions) (*GoogleProv }, } - if opts.ServiceAccountJSON != "" { - file, err := os.Open(opts.ServiceAccountJSON) - if err != nil { - return nil, fmt.Errorf("invalid Google credentials file: %s", opts.ServiceAccountJSON) - } - + if opts.ServiceAccountJSON != "" || opts.UseApplicationDefaultCredentials { // Backwards compatibility with `--google-group` option if len(opts.Groups) > 0 { provider.setAllowedGroups(opts.Groups) } - provider.setGroupRestriction(opts.Groups, opts.AdminEmail, file) + + provider.setGroupRestriction(opts) } return provider, nil @@ -214,13 +212,13 @@ func (p *GoogleProvider) EnrichSession(_ context.Context, s *sessions.SessionSta // account credentials. // // TODO (@NickMeves) - Unit Test this OR refactor away from groupValidator func -func (p *GoogleProvider) setGroupRestriction(groups []string, adminEmail string, credentialsReader io.Reader) { - adminService := getAdminService(adminEmail, credentialsReader) +func (p *GoogleProvider) setGroupRestriction(opts options.GoogleOptions) { + adminService := getAdminService(opts) p.groupValidator = func(s *sessions.SessionState) bool { // Reset our saved Groups in case membership changed // This is used by `Authorize` on every request - s.Groups = make([]string, 0, len(groups)) - for _, group := range groups { + s.Groups = make([]string, 0, len(opts.Groups)) + for _, group := range opts.Groups { if userInGroup(adminService, group, s.Email) { s.Groups = append(s.Groups, group) } @@ -229,19 +227,37 @@ func (p *GoogleProvider) setGroupRestriction(groups []string, adminEmail string, } } -func getAdminService(adminEmail string, credentialsReader io.Reader) *admin.Service { - data, err := io.ReadAll(credentialsReader) - if err != nil { - logger.Fatal("can't read Google credentials file:", err) - } - conf, err := google.JWTConfigFromJSON(data, admin.AdminDirectoryUserReadonlyScope, admin.AdminDirectoryGroupReadonlyScope) - if err != nil { - logger.Fatal("can't load Google credentials file:", err) - } - conf.Subject = adminEmail - +func getAdminService(opts options.GoogleOptions) *admin.Service { ctx := context.Background() - client := conf.Client(ctx) + var client *http.Client + if opts.UseApplicationDefaultCredentials { + ts, err := google.FindDefaultCredentialsWithParams(ctx, google.CredentialsParams{ + Subject: opts.AdminEmail, + Scopes: []string{admin.AdminDirectoryGroupReadonlyScope, admin.AdminDirectoryUserReadonlyScope}, + }) + if err != nil { + logger.Fatal("failed to fetch application default credentials: ", err) + } + client = oauth2.NewClient(ctx, ts.TokenSource) + } else { + credentialsReader, err := os.Open(opts.ServiceAccountJSON) + if err != nil { + logger.Fatal("couldn't open Google credentials file: ", err) + return nil + } + + data, err := io.ReadAll(credentialsReader) + if err != nil { + logger.Fatal("can't read Google credentials file:", err) + } + + conf, err := google.JWTConfigFromJSON(data, admin.AdminDirectoryUserReadonlyScope, admin.AdminDirectoryGroupReadonlyScope) + if err != nil { + logger.Fatal("can't load Google credentials file:", err) + } + conf.Subject = opts.AdminEmail + client = conf.Client(ctx) + } adminService, err := admin.NewService(ctx, option.WithHTTPClient(client)) if err != nil { logger.Fatal(err)