diff --git a/CHANGELOG.md b/CHANGELOG.md index 642dee4f..88baaafe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Changes since v7.1.2 +- [#947](https://github.com/oauth2-proxy/oauth2-proxy/pull/947) Multiple provider ingestion and validation in alpha options (first stage: [#926](https://github.com/oauth2-proxy/oauth2-proxy/issues/926)) (@yanasega) + # V7.1.2 ## Release Highlights diff --git a/contrib/local-environment/oauth2-proxy-alpha-config.cfg b/contrib/local-environment/oauth2-proxy-alpha-config.cfg index 1f1448fc..89e5a5b2 100644 --- a/contrib/local-environment/oauth2-proxy-alpha-config.cfg +++ b/contrib/local-environment/oauth2-proxy-alpha-config.cfg @@ -1,10 +1,5 @@ http_address="0.0.0.0:4180" cookie_secret="OQINaROshtE9TcZkNAm-5Zs2Pv3xaWytBmc5W7sPX7w=" -provider="oidc" email_domains="example.com" -oidc_issuer_url="http://dex.localhost:4190/dex" -client_secret="b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK" -client_id="oauth2-proxy" cookie_secure="false" - redirect_url="http://localhost:4180/oauth2/callback" diff --git a/contrib/local-environment/oauth2-proxy-alpha-config.yaml b/contrib/local-environment/oauth2-proxy-alpha-config.yaml index b88b386a..1c9d3df6 100644 --- a/contrib/local-environment/oauth2-proxy-alpha-config.yaml +++ b/contrib/local-environment/oauth2-proxy-alpha-config.yaml @@ -15,3 +15,9 @@ injectRequestHeaders: - name: X-Forwarded-Preferred-Username values: - claim: preferred_username +providers: +- provider: oidc + clientSecret: b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK + clientID: oauth2-proxy + oidcConfig: + oidcIssuerURL: http://dex.localhost:4190/dex diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md index 1fe3f1f7..7f855b22 100644 --- a/docs/docs/configuration/alpha_config.md +++ b/docs/docs/configuration/alpha_config.md @@ -119,6 +119,28 @@ They may change between releases without notice. | `injectResponseHeaders` | _[[]Header](#header)_ | InjectResponseHeaders is used to configure headers that should be added
to responses from the proxy.
This is typically used when using the proxy as an external authentication
provider in conjunction with another proxy such as NGINX and its
auth_request module.
Headers may source values from either the authenticated user's session
or from a static secret value. | | `server` | _[Server](#server)_ | Server is used to configure the HTTP(S) server for the proxy application.
You may choose to run both HTTP and HTTPS servers simultaneously.
This can be done by setting the BindAddress and the SecureBindAddress simultaneously.
To use the secure server you must configure a TLS certificate and key. | | `metricsServer` | _[Server](#server)_ | MetricsServer is used to configure the HTTP(S) server for metrics.
You may choose to run both HTTP and HTTPS servers simultaneously.
This can be done by setting the BindAddress and the SecureBindAddress simultaneously.
To use the secure server you must configure a TLS certificate and key. | +| `providers` | _[Providers](#providers)_ | Providers is used to configure multiple providers. | + +### AzureOptions + +(**Appears on:** [Provider](#provider)) + + + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `tenant` | _string_ | Tenant directs to a tenant-specific or common (tenant-independent) endpoint
Default value is 'commmon' | + +### BitbucketOptions + +(**Appears on:** [Provider](#provider)) + + + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `team` | _string_ | Team sets restrict logins to members of this team | +| `repository` | _string_ | Repository sets restrict logins to user with access to this repository | ### ClaimSource @@ -143,6 +165,43 @@ each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45 Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". +### GitHubOptions + +(**Appears on:** [Provider](#provider)) + + + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `org` | _string_ | Org sets restrict logins to members of this organisation | +| `team` | _string_ | Team sets restrict logins to members of this team | +| `repo` | _string_ | Repo sets restrict logins to collaborators of this repository | +| `token` | _string_ | Token is the token to use when verifying repository collaborators
it must have push access to the repository | +| `users` | _[]string_ | Users allows users with these usernames to login
even if they do not belong to the specified org and team or collaborators | + +### GitLabOptions + +(**Appears on:** [Provider](#provider)) + + + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `group` | _[]string_ | Group sets restrict logins to members of this group | +| `projects` | _[]string_ | Projects restricts logins to members of any of these projects | + +### GoogleOptions + +(**Appears on:** [Provider](#provider)) + + + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `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 | + ### Header (**Appears on:** [AlphaOptions](#alphaoptions)) @@ -172,6 +231,88 @@ make up the header value | `prefix` | _string_ | Prefix is an optional prefix that will be prepended to the value of the
claim if it is non-empty. | | `basicAuthPassword` | _[SecretSource](#secretsource)_ | BasicAuthPassword converts this claim into a basic auth header.
Note the value of claim will become the basic auth username and the
basicAuthPassword will be used as the password value. | +### KeycloakOptions + +(**Appears on:** [Provider](#provider)) + + + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `groups` | _[]string_ | Group enables to restrict login to members of indicated group | + +### LoginGovOptions + +(**Appears on:** [Provider](#provider)) + + + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `jwtKey` | _string_ | JWTKey is a private key in PEM format used to sign JWT, | +| `jwtKeyFile` | _string_ | JWTKeyFile is a path to the private key file in PEM format used to sign the JWT | +| `pubjwkURL` | _string_ | PubJWKURL is the JWK pubkey access endpoint | + +### OIDCOptions + +(**Appears on:** [Provider](#provider)) + + + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `issuerURL` | _string_ | IssuerURL is the OpenID Connect issuer URL
eg: https://accounts.google.com | +| `insecureAllowUnverifiedEmail` | _bool_ | InsecureAllowUnverifiedEmail prevents failures if an email address in an id_token is not verified
default set to 'false' | +| `insecureSkipIssuerVerification` | _bool_ | InsecureSkipIssuerVerification skips verification of ID token issuers. When false, ID Token Issuers must match the OIDC discovery URL
default set to 'false' | +| `skipDiscovery` | _bool_ | SkipDiscovery allows to skip OIDC discovery and use manually supplied Endpoints
default set to 'false' | +| `jwksURL` | _string_ | JwksURL is the OpenID Connect JWKS URL
eg: https://www.googleapis.com/oauth2/v3/certs | +| `emailClaim` | _string_ | EmailClaim indicates which claim contains the user email,
default set to 'email' | +| `groupsClaim` | _string_ | GroupsClaim indicates which claim contains the user groups
default set to 'groups' | +| `userIDClaim` | _string_ | UserIDClaim indicates which claim contains the user ID
default set to 'email' | + +### Provider + +(**Appears on:** [Providers](#providers)) + +Provider holds all configuration for a single provider + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `clientID` | _string_ | ClientID is the OAuth Client ID that is defined in the provider
This value is required for all providers. | +| `clientSecret` | _string_ | ClientSecret is the OAuth Client Secret that is defined in the provider
This value is required for all providers. | +| `clientSecretFile` | _string_ | ClientSecretFile is the name of the file
containing the OAuth Client Secret, it will be used if ClientSecret is not set. | +| `keycloakConfig` | _[KeycloakOptions](#keycloakoptions)_ | KeycloakConfig holds all configurations for Keycloak provider. | +| `azureConfig` | _[AzureOptions](#azureoptions)_ | AzureConfig holds all configurations for Azure provider. | +| `bitbucketConfig` | _[BitbucketOptions](#bitbucketoptions)_ | BitbucketConfig holds all configurations for Bitbucket provider. | +| `githubConfig` | _[GitHubOptions](#githuboptions)_ | GitHubConfig holds all configurations for GitHubC provider. | +| `gitlabConfig` | _[GitLabOptions](#gitlaboptions)_ | GitLabConfig holds all configurations for GitLab provider. | +| `googleConfig` | _[GoogleOptions](#googleoptions)_ | GoogleConfig holds all configurations for Google provider. | +| `oidcConfig` | _[OIDCOptions](#oidcoptions)_ | OIDCConfig holds all configurations for OIDC provider
or providers utilize OIDC configurations. | +| `loginGovConfig` | _[LoginGovOptions](#logingovoptions)_ | LoginGovConfig holds all configurations for LoginGov provider. | +| `id` | _string_ | ID should be a unique identifier for the provider.
This value is required for all providers. | +| `provider` | _string_ | Type is the OAuth provider
must be set from the supported providers group,
otherwise 'Google' is set as default | +| `name` | _string_ | Name is the providers display name
if set, it will be shown to the users in the login page. | +| `caFiles` | _[]string_ | CAFiles is a list of paths to CA certificates that should be used when connecting to the provider.
If not specified, the default Go trust sources are used instead | +| `loginURL` | _string_ | LoginURL is the authentication endpoint | +| `redeemURL` | _string_ | RedeemURL is the token redemption endpoint | +| `profileURL` | _string_ | ProfileURL is the profile access endpoint | +| `resource` | _string_ | ProtectedResource is the resource that is protected (Azure AD only) | +| `validateURL` | _string_ | ValidateURL is the access token validation endpoint | +| `scope` | _string_ | Scope is the OAuth scope specification | +| `prompt` | _string_ | Prompt is OIDC prompt | +| `approvalPrompt` | _string_ | ApprovalPrompt is the OAuth approval_prompt
default is set to 'force' | +| `allowedGroups` | _[]string_ | AllowedGroups is a list of restrict logins to members of this group | +| `acrValues` | _string_ | AcrValues is a string of acr values | + +### Providers + +#### ([[]Provider](#provider) alias) + +(**Appears on:** [AlphaOptions](#alphaoptions)) + +Providers is a collection of definitions for providers. + + ### SecretSource (**Appears on:** [ClaimSource](#claimsource), [HeaderValue](#headervalue), [TLS](#tls)) diff --git a/go.sum b/go.sum index 0c506971..0a1b46f9 100644 --- a/go.sum +++ b/go.sum @@ -398,6 +398,7 @@ github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3 github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/main_test.go b/main_test.go index 3273abfa..5c7d5ed1 100644 --- a/main_test.go +++ b/main_test.go @@ -19,6 +19,8 @@ http_address="127.0.0.1:4180" upstreams="http://httpbin" set_basic_auth="true" basic_auth_password="super-secret-password" +client_id="oauth2-proxy" +client_secret="b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK" ` const testAlphaConfig = ` @@ -57,15 +59,23 @@ injectResponseHeaders: value: c3VwZXItc2VjcmV0LXBhc3N3b3Jk server: bindAddress: "127.0.0.1:4180" +providers: +- provider: google + ID: google=oauth2-proxy + clientSecret: b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK + clientID: oauth2-proxy + approvalPrompt: force + azureConfig: + tenant: common + oidcConfig: + groupsClaim: groups + emailClaim: email + userIDClaim: email ` const testCoreConfig = ` cookie_secret="OQINaROshtE9TcZkNAm-5Zs2Pv3xaWytBmc5W7sPX7w=" -provider="oidc" email_domains="example.com" -oidc_issuer_url="http://dex.localhost:4190/dex" -client_secret="b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK" -client_id="oauth2-proxy" cookie_secure="false" redirect_url="http://localhost:4180/oauth2/callback" @@ -85,11 +95,7 @@ redirect_url="http://localhost:4180/oauth2/callback" Expect(err).ToNot(HaveOccurred()) opts.Cookie.Secret = "OQINaROshtE9TcZkNAm-5Zs2Pv3xaWytBmc5W7sPX7w=" - opts.ProviderType = "oidc" opts.EmailDomains = []string{"example.com"} - opts.OIDCIssuerURL = "http://dex.localhost:4190/dex" - opts.ClientSecret = "b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK" - opts.ClientID = "oauth2-proxy" opts.Cookie.Secure = false opts.RawRedirectURL = "http://localhost:4180/oauth2/callback" @@ -121,6 +127,24 @@ redirect_url="http://localhost:4180/oauth2/callback" opts.InjectRequestHeaders = append([]options.Header{authHeader}, opts.InjectRequestHeaders...) opts.InjectResponseHeaders = append(opts.InjectResponseHeaders, authHeader) + + opts.Providers = options.Providers{ + { + ID: "google=oauth2-proxy", + Type: "google", + ClientSecret: "b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK", + ClientID: "oauth2-proxy", + AzureConfig: options.AzureOptions{ + Tenant: "common", + }, + OIDCConfig: options.OIDCOptions{ + GroupsClaim: "groups", + EmailClaim: "email", + UserIDClaim: "email", + }, + ApprovalPrompt: "force", + }, + } return opts } @@ -204,7 +228,7 @@ redirect_url="http://localhost:4180/oauth2/callback" configContent: testCoreConfig, alphaConfigContent: testAlphaConfig + ":", expectedOptions: func() *options.Options { return nil }, - expectedErr: errors.New("failed to load alpha options: error unmarshalling config: error converting YAML to JSON: yaml: line 36: did not find expected key"), + expectedErr: errors.New("failed to load alpha options: error unmarshalling config: error converting YAML to JSON: yaml: line 48: did not find expected key"), }), Entry("with alpha configuration and bad core configuration", loadConfigurationTableInput{ configContent: testCoreConfig + "unknown_field=\"something\"", diff --git a/oauthproxy.go b/oauthproxy.go index 8ef5cd03..7081f6e2 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -122,7 +122,7 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr Footer: opts.Templates.Footer, Version: VERSION, Debug: opts.Templates.Debug, - ProviderName: buildProviderName(opts.GetProvider(), opts.ProviderName), + ProviderName: buildProviderName(opts.GetProvider(), opts.Providers[0].Name), SignInMessage: buildSignInMessage(opts), DisplayLoginForm: basicAuthValidator != nil && opts.Templates.DisplayLoginForm, }) @@ -136,7 +136,7 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr } if opts.SkipJwtBearerTokens { - logger.Printf("Skipping JWT tokens from configured OIDC issuer: %q", opts.OIDCIssuerURL) + logger.Printf("Skipping JWT tokens from configured OIDC issuer: %q", opts.Providers[0].OIDCConfig.IssuerURL) for _, issuer := range opts.ExtraJwtIssuers { logger.Printf("Skipping JWT tokens from extra JWT issuer: %q", issuer) } @@ -146,7 +146,7 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr redirectURL.Path = fmt.Sprintf("%s/callback", opts.ProxyPrefix) } - logger.Printf("OAuthProxy configured for %s Client ID: %s", opts.GetProvider().Data().ProviderName, opts.ClientID) + logger.Printf("OAuthProxy configured for %s Client ID: %s", opts.GetProvider().Data().ProviderName, opts.Providers[0].ClientID) refresh := "disabled" if opts.Cookie.Refresh != time.Duration(0) { refresh = fmt.Sprintf("after %s", opts.Cookie.Refresh) diff --git a/oauthproxy_test.go b/oauthproxy_test.go index 5f2cd32e..e8d705ed 100644 --- a/oauthproxy_test.go +++ b/oauthproxy_test.go @@ -981,7 +981,7 @@ func NewProcessCookieTest(opts ProcessCookieTestOpts, modifiers ...OptionsModifi ProviderData: &providers.ProviderData{}, ValidToken: opts.providerValidateCookieResponse, } - pcTest.proxy.provider.(*TestProvider).SetAllowedGroups(pcTest.opts.AllowedGroups) + pcTest.proxy.provider.(*TestProvider).SetAllowedGroups(pcTest.opts.Providers[0].AllowedGroups) pcTest.rw = httptest.NewRecorder() pcTest.req, _ = http.NewRequest("GET", "/", strings.NewReader("")) @@ -1322,7 +1322,7 @@ func TestAuthOnlyEndpointSetXAuthRequestHeaders(t *testing.T) { }, }, } - pcTest.opts.AllowedGroups = []string{"oauth_groups"} + pcTest.opts.Providers[0].AllowedGroups = []string{"oauth_groups"} err := validation.Validate(pcTest.opts) assert.NoError(t, err) @@ -2292,8 +2292,9 @@ func Test_noCacheHeaders(t *testing.T) { func baseTestOptions() *options.Options { opts := options.NewOptions() opts.Cookie.Secret = rawCookieSecret - opts.ClientID = clientID - opts.ClientSecret = clientSecret + opts.Providers[0].ID = "providerID" + opts.Providers[0].ClientID = clientID + opts.Providers[0].ClientSecret = clientSecret opts.EmailDomains = []string{"*"} // Default injected headers for legacy configuration @@ -2786,7 +2787,7 @@ func TestProxyAllowedGroups(t *testing.T) { t.Cleanup(upstreamServer.Close) test, err := NewProcessCookieTestWithOptionsModifiers(func(opts *options.Options) { - opts.AllowedGroups = tt.allowedGroups + opts.Providers[0].AllowedGroups = tt.allowedGroups opts.UpstreamServers = options.Upstreams{ { ID: upstreamServer.URL, @@ -2915,7 +2916,7 @@ func TestAuthOnlyAllowedGroups(t *testing.T) { } test, err := NewAuthOnlyEndpointTest(tc.querystring, func(opts *options.Options) { - opts.AllowedGroups = tc.allowedGroups + opts.Providers[0].AllowedGroups = tc.allowedGroups }) if err != nil { t.Fatal(err) diff --git a/pkg/apis/options/alpha_options.go b/pkg/apis/options/alpha_options.go index c9a86310..ecfd81c8 100644 --- a/pkg/apis/options/alpha_options.go +++ b/pkg/apis/options/alpha_options.go @@ -40,6 +40,9 @@ type AlphaOptions struct { // This can be done by setting the BindAddress and the SecureBindAddress simultaneously. // To use the secure server you must configure a TLS certificate and key. MetricsServer Server `json:"metricsServer,omitempty"` + + // Providers is used to configure multiple providers. + Providers Providers `json:"providers,omitempty"` } // MergeInto replaces alpha options in the Options struct with the values @@ -50,6 +53,8 @@ func (a *AlphaOptions) MergeInto(opts *Options) { opts.InjectResponseHeaders = a.InjectResponseHeaders opts.Server = a.Server opts.MetricsServer = a.MetricsServer + opts.Providers = a.Providers + } // ExtractFrom populates the fields in the AlphaOptions with the values from @@ -60,4 +65,5 @@ func (a *AlphaOptions) ExtractFrom(opts *Options) { a.InjectResponseHeaders = opts.InjectResponseHeaders a.Server = opts.Server a.MetricsServer = opts.MetricsServer + a.Providers = opts.Providers } diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index 619069f3..5519963a 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -8,6 +8,7 @@ import ( "time" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" + "github.com/oauth2-proxy/oauth2-proxy/v7/providers" "github.com/spf13/pflag" ) @@ -21,6 +22,9 @@ type LegacyOptions struct { // Legacy options for the server address and TLS LegacyServer LegacyServer `cfg:",squash"` + // Legacy options for single provider + LegacyProvider LegacyProvider `cfg:",squash"` + Options Options `cfg:",squash"` } @@ -43,6 +47,15 @@ func NewLegacyOptions() *LegacyOptions { HTTPSAddress: ":443", }, + LegacyProvider: LegacyProvider{ + ProviderType: "google", + AzureTenant: "common", + ApprovalPrompt: "force", + UserIDClaim: "email", + OIDCEmailClaim: "email", + OIDCGroupsClaim: "groups", + }, + Options: *NewOptions(), } } @@ -53,6 +66,7 @@ func NewLegacyFlagSet() *pflag.FlagSet { flagSet.AddFlagSet(legacyUpstreamsFlagSet()) flagSet.AddFlagSet(legacyHeadersFlagSet()) flagSet.AddFlagSet(legacyServerFlagset()) + flagSet.AddFlagSet(legacyProviderFlagSet()) return flagSet } @@ -65,10 +79,17 @@ func (l *LegacyOptions) ToOptions() (*Options, error) { l.Options.UpstreamServers = upstreams l.Options.InjectRequestHeaders, l.Options.InjectResponseHeaders = l.LegacyHeaders.convert() + l.Options.Server, l.Options.MetricsServer = l.LegacyServer.convert() l.Options.LegacyPreferEmailToUser = l.LegacyHeaders.PreferEmailToUser + providers, err := l.LegacyProvider.convert() + if err != nil { + return nil, fmt.Errorf("error converting provider: %v", err) + } + l.Options.Providers = providers + return &l.Options, nil } @@ -443,6 +464,106 @@ func legacyServerFlagset() *pflag.FlagSet { return flagSet } +type LegacyProvider struct { + ClientID string `flag:"client-id" cfg:"client_id"` + 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"` + 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"` + + // These options allow for other providers besides Google, with + // potential overrides. + ProviderType string `flag:"provider" cfg:"provider"` + ProviderName string `flag:"provider-display-name" cfg:"provider_display_name"` + ProviderCAFiles []string `flag:"provider-ca-file" cfg:"provider_ca_files"` + OIDCIssuerURL string `flag:"oidc-issuer-url" cfg:"oidc_issuer_url"` + InsecureOIDCAllowUnverifiedEmail bool `flag:"insecure-oidc-allow-unverified-email" cfg:"insecure_oidc_allow_unverified_email"` + InsecureOIDCSkipIssuerVerification bool `flag:"insecure-oidc-skip-issuer-verification" cfg:"insecure_oidc_skip_issuer_verification"` + SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery"` + OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url"` + OIDCEmailClaim string `flag:"oidc-email-claim" cfg:"oidc_email_claim"` + OIDCGroupsClaim string `flag:"oidc-groups-claim" cfg:"oidc_groups_claim"` + LoginURL string `flag:"login-url" cfg:"login_url"` + RedeemURL string `flag:"redeem-url" cfg:"redeem_url"` + ProfileURL string `flag:"profile-url" cfg:"profile_url"` + ProtectedResource string `flag:"resource" cfg:"resource"` + ValidateURL string `flag:"validate-url" cfg:"validate_url"` + Scope string `flag:"scope" cfg:"scope"` + Prompt string `flag:"prompt" cfg:"prompt"` + ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0 + UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"` + AllowedGroups []string `flag:"allowed-group" cfg:"allowed_groups"` + + AcrValues string `flag:"acr-values" cfg:"acr_values"` + JWTKey string `flag:"jwt-key" cfg:"jwt_key"` + JWTKeyFile string `flag:"jwt-key-file" cfg:"jwt_key_file"` + PubJWKURL string `flag:"pubjwk-url" cfg:"pubjwk_url"` +} + +func legacyProviderFlagSet() *pflag.FlagSet { + flagSet := pflag.NewFlagSet("provider", pflag.ExitOnError) + + flagSet.StringSlice("keycloak-group", []string{}, "restrict logins to members of these groups (may be given multiple times)") + flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.") + flagSet.String("bitbucket-team", "", "restrict logins to members of this team") + flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository") + flagSet.String("github-org", "", "restrict logins to members of this organisation") + flagSet.String("github-team", "", "restrict logins to members of this team") + flagSet.String("github-repo", "", "restrict logins to collaborators of this repository") + flagSet.String("github-token", "", "the token to use when verifying repository collaborators (must have push access to the repository)") + 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") + + flagSet.String("provider", "google", "OAuth provider") + flagSet.String("provider-display-name", "", "Provider display name") + flagSet.StringSlice("provider-ca-file", []string{}, "One or more paths to CA certificates that should be used when connecting to the provider. If not specified, the default Go trust sources are used instead.") + flagSet.String("oidc-issuer-url", "", "OpenID Connect issuer URL (ie: https://accounts.google.com)") + flagSet.Bool("insecure-oidc-allow-unverified-email", false, "Don't fail if an email address in an id_token is not verified") + flagSet.Bool("insecure-oidc-skip-issuer-verification", false, "Do not verify if issuer matches OIDC discovery URL") + flagSet.Bool("skip-oidc-discovery", false, "Skip OIDC discovery and use manually supplied Endpoints") + flagSet.String("oidc-jwks-url", "", "OpenID Connect JWKS URL (ie: https://www.googleapis.com/oauth2/v3/certs)") + flagSet.String("oidc-groups-claim", providers.OIDCGroupsClaim, "which OIDC claim contains the user groups") + flagSet.String("oidc-email-claim", providers.OIDCEmailClaim, "which OIDC claim contains the user's email") + flagSet.String("login-url", "", "Authentication endpoint") + flagSet.String("redeem-url", "", "Token redemption endpoint") + flagSet.String("profile-url", "", "Profile access endpoint") + flagSet.String("resource", "", "The resource that is protected (Azure AD only)") + flagSet.String("validate-url", "", "Access token validation endpoint") + flagSet.String("scope", "", "OAuth scope specification") + flagSet.String("prompt", "", "OIDC prompt") + flagSet.String("approval-prompt", "force", "OAuth approval_prompt") + + flagSet.String("acr-values", "", "acr values string: optional") + flagSet.String("jwt-key", "", "private key in PEM format used to sign JWT, so that you can say something like -jwt-key=\"${OAUTH2_PROXY_JWT_KEY}\": required by login.gov") + flagSet.String("jwt-key-file", "", "path to the private key file in PEM format used to sign the JWT so that you can say something like -jwt-key-file=/etc/ssl/private/jwt_signing_key.pem: required by login.gov") + flagSet.String("pubjwk-url", "", "JWK pubkey access endpoint: required by login.gov") + + flagSet.String("user-id-claim", providers.OIDCEmailClaim, "(DEPRECATED for `oidc-email-claim`) which claim contains the user ID") + flagSet.StringSlice("allowed-group", []string{}, "restrict logins to members of this group (may be given multiple times)") + + return flagSet +} + func (l LegacyServer) convert() (Server, Server) { appServer := Server{ BindAddress: l.HTTPAddress, @@ -482,3 +603,91 @@ func (l LegacyServer) convert() (Server, Server) { return appServer, metricsServer } + +func (l *LegacyProvider) convert() (Providers, error) { + providers := Providers{} + + provider := Provider{ + ClientID: l.ClientID, + ClientSecret: l.ClientSecret, + ClientSecretFile: l.ClientSecretFile, + Type: l.ProviderType, + CAFiles: l.ProviderCAFiles, + LoginURL: l.LoginURL, + RedeemURL: l.RedeemURL, + ProfileURL: l.ProfileURL, + ProtectedResource: l.ProtectedResource, + ValidateURL: l.ValidateURL, + Scope: l.Scope, + Prompt: l.Prompt, + ApprovalPrompt: l.ApprovalPrompt, + AllowedGroups: l.AllowedGroups, + AcrValues: l.AcrValues, + } + + // This part is out of the switch section for all providers that support OIDC + provider.OIDCConfig = OIDCOptions{ + IssuerURL: l.OIDCIssuerURL, + InsecureAllowUnverifiedEmail: l.InsecureOIDCAllowUnverifiedEmail, + InsecureSkipIssuerVerification: l.InsecureOIDCSkipIssuerVerification, + SkipDiscovery: l.SkipOIDCDiscovery, + JwksURL: l.OIDCJwksURL, + UserIDClaim: l.UserIDClaim, + EmailClaim: l.OIDCEmailClaim, + GroupsClaim: l.OIDCGroupsClaim, + } + + // This part is out of the switch section because azure has a default tenant + // that needs to be added from legacy options + provider.AzureConfig = AzureOptions{ + Tenant: l.AzureTenant, + } + + switch provider.Type { + case "github": + provider.GitHubConfig = GitHubOptions{ + Org: l.GitHubOrg, + Team: l.GitHubTeam, + Repo: l.GitHubRepo, + Token: l.GitHubToken, + Users: l.GitHubUsers, + } + case "keycloak": + provider.KeycloakConfig = KeycloakOptions{ + Groups: l.KeycloakGroups, + } + case "gitlab": + provider.GitLabConfig = GitLabOptions{ + Group: l.GitLabGroup, + Projects: l.GitLabProjects, + } + case "login.gov": + provider.LoginGovConfig = LoginGovOptions{ + JWTKey: l.JWTKey, + JWTKeyFile: l.JWTKeyFile, + PubJWKURL: l.PubJWKURL, + } + case "bitbucket": + provider.BitbucketConfig = BitbucketOptions{ + Team: l.BitbucketTeam, + Repository: l.BitbucketRepository, + } + case "google": + provider.GoogleConfig = GoogleOptions{ + Groups: l.GoogleGroups, + AdminEmail: l.GoogleAdminEmail, + ServiceAccountJSON: l.GoogleServiceAccountJSON, + } + } + + if l.ProviderName != "" { + provider.ID = l.ProviderName + provider.Name = l.ProviderName + } else { + provider.ID = l.ProviderType + "=" + l.ClientID + } + + providers = append(providers, provider) + + return providers, nil +} diff --git a/pkg/apis/options/legacy_options_test.go b/pkg/apis/options/legacy_options_test.go index 9f397f6f..ee593142 100644 --- a/pkg/apis/options/legacy_options_test.go +++ b/pkg/apis/options/legacy_options_test.go @@ -22,6 +22,7 @@ var _ = Describe("Legacy Options", func() { legacyOpts.LegacyUpstreams.ProxyWebSockets = true legacyOpts.LegacyUpstreams.SSLUpstreamInsecureSkipVerify = true legacyOpts.LegacyUpstreams.Upstreams = []string{"http://foo.bar/baz", "file:///var/lib/website#/bar", "static://204"} + legacyOpts.LegacyProvider.ClientID = "oauth-proxy" truth := true staticCode := 204 @@ -110,6 +111,9 @@ var _ = Describe("Legacy Options", func() { BindAddress: "127.0.0.1:4180", } + opts.Providers[0].ClientID = "oauth-proxy" + opts.Providers[0].ID = "google=oauth-proxy" + converted, err := legacyOpts.ToOptions() Expect(err).ToNot(HaveOccurred()) Expect(converted).To(Equal(opts)) @@ -196,9 +200,9 @@ var _ = Describe("Legacy Options", func() { invalidHTTPErrMsg := "could not parse upstream \":foo\": parse \":foo\": missing protocol scheme" DescribeTable("convertLegacyUpstreams", - func(o *convertUpstreamsTableInput) { + func(in *convertUpstreamsTableInput) { legacyUpstreams := LegacyUpstreams{ - Upstreams: o.upstreamStrings, + Upstreams: in.upstreamStrings, SSLUpstreamInsecureSkipVerify: skipVerify, PassHostHeader: passHostHeader, ProxyWebSockets: proxyWebSockets, @@ -207,14 +211,14 @@ var _ = Describe("Legacy Options", func() { upstreams, err := legacyUpstreams.convert() - if o.errMsg != "" { + if in.errMsg != "" { Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(Equal(o.errMsg)) + Expect(err.Error()).To(Equal(in.errMsg)) } else { Expect(err).ToNot(HaveOccurred()) } - Expect(upstreams).To(ConsistOf(o.expectedUpstreams)) + Expect(upstreams).To(ConsistOf(in.expectedUpstreams)) }, Entry("with no upstreams", &convertUpstreamsTableInput{ upstreamStrings: []string{}, @@ -850,6 +854,87 @@ var _ = Describe("Legacy Options", func() { }, }), ) + }) + Context("Legacy Providers", func() { + type convertProvidersTableInput struct { + legacyProvider LegacyProvider + expectedProviders Providers + errMsg string + } + + // Non defaults for these options + clientID := "abcd" + + defaultProvider := Provider{ + ID: "google=" + clientID, + ClientID: clientID, + Type: "google", + } + defaultLegacyProvider := LegacyProvider{ + ClientID: clientID, + ProviderType: "google", + } + + displayNameProvider := Provider{ + ID: "displayName", + Name: "displayName", + ClientID: clientID, + Type: "google", + } + + displayNameLegacyProvider := LegacyProvider{ + ClientID: clientID, + ProviderName: "displayName", + ProviderType: "google", + } + + internalConfigProvider := Provider{ + ID: "google=" + clientID, + ClientID: clientID, + Type: "google", + GoogleConfig: GoogleOptions{ + AdminEmail: "email@email.com", + ServiceAccountJSON: "test.json", + Groups: []string{"1", "2"}, + }, + } + + internalConfigLegacyProvider := LegacyProvider{ + ClientID: clientID, + ProviderType: "google", + GoogleAdminEmail: "email@email.com", + GoogleServiceAccountJSON: "test.json", + GoogleGroups: []string{"1", "2"}, + } + DescribeTable("convertLegacyProviders", + func(in *convertProvidersTableInput) { + providers, err := in.legacyProvider.convert() + + if in.errMsg != "" { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal(in.errMsg)) + } else { + Expect(err).ToNot(HaveOccurred()) + } + + Expect(providers).To(ConsistOf(in.expectedProviders)) + }, + Entry("with default provider", &convertProvidersTableInput{ + legacyProvider: defaultLegacyProvider, + expectedProviders: Providers{defaultProvider}, + errMsg: "", + }), + Entry("with provider display name", &convertProvidersTableInput{ + legacyProvider: displayNameLegacyProvider, + expectedProviders: Providers{displayNameProvider}, + errMsg: "", + }), + Entry("with internal provider config", &convertProvidersTableInput{ + legacyProvider: internalConfigLegacyProvider, + expectedProviders: Providers{internalConfigProvider}, + errMsg: "", + }), + ) }) }) diff --git a/pkg/apis/options/load_test.go b/pkg/apis/options/load_test.go index 28fd79e6..145ef3bb 100644 --- a/pkg/apis/options/load_test.go +++ b/pkg/apis/options/load_test.go @@ -14,6 +14,49 @@ import ( ) var _ = Describe("Load", func() { + optionsWithNilProvider := NewOptions() + optionsWithNilProvider.Providers = nil + + legacyOptionsWithNilProvider := &LegacyOptions{ + LegacyUpstreams: LegacyUpstreams{ + PassHostHeader: true, + ProxyWebSockets: true, + FlushInterval: DefaultUpstreamFlushInterval, + }, + + LegacyHeaders: LegacyHeaders{ + PassBasicAuth: true, + PassUserHeaders: true, + SkipAuthStripHeaders: true, + }, + + LegacyServer: LegacyServer{ + HTTPAddress: "127.0.0.1:4180", + HTTPSAddress: ":443", + }, + + LegacyProvider: LegacyProvider{ + ProviderType: "google", + AzureTenant: "common", + ApprovalPrompt: "force", + UserIDClaim: "email", + OIDCEmailClaim: "email", + OIDCGroupsClaim: "groups", + }, + + Options: Options{ + ProxyPrefix: "/oauth2", + PingPath: "/ping", + RealClientIPHeader: "X-Real-IP", + ForceHTTPS: false, + Cookie: cookieDefaults(), + Session: sessionOptionsDefaults(), + Templates: templatesDefaults(), + SkipAuthPreflight: false, + Logging: loggingDefaults(), + }, + } + Context("with a testOptions structure", func() { type TestOptionSubStruct struct { StringSliceOption []string `flag:"string-slice-option" cfg:"string_slice_option"` @@ -294,12 +337,12 @@ var _ = Describe("Load", func() { Entry("with an empty Options struct, should return default values", &testOptionsTableInput{ flagSet: NewFlagSet, input: &Options{}, - expectedOutput: NewOptions(), + expectedOutput: optionsWithNilProvider, }), Entry("with an empty LegacyOptions struct, should return default values", &testOptionsTableInput{ flagSet: NewLegacyFlagSet, input: &LegacyOptions{}, - expectedOutput: NewLegacyOptions(), + expectedOutput: legacyOptionsWithNilProvider, }), ) }) diff --git a/pkg/apis/options/options.go b/pkg/apis/options/options.go index 58ddd2e8..03b2047e 100644 --- a/pkg/apis/options/options.go +++ b/pkg/apis/options/options.go @@ -27,29 +27,12 @@ type Options struct { TrustedIPs []string `flag:"trusted-ip" cfg:"trusted_ips"` ForceHTTPS bool `flag:"force-https" cfg:"force_https"` RawRedirectURL string `flag:"redirect-url" cfg:"redirect_url"` - ClientID string `flag:"client-id" cfg:"client_id"` - ClientSecret string `flag:"client-secret" cfg:"client_secret"` - ClientSecretFile string `flag:"client-secret-file" cfg:"client_secret_file"` - AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"` - KeycloakGroups []string `flag:"keycloak-group" cfg:"keycloak_groups"` - AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"` - BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"` - BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"` - EmailDomains []string `flag:"email-domain" cfg:"email_domains"` - WhitelistDomains []string `flag:"whitelist-domain" cfg:"whitelist_domains"` - 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"` - HtpasswdFile string `flag:"htpasswd-file" cfg:"htpasswd_file"` - HtpasswdUserGroups []string `flag:"htpasswd-user-group" cfg:"htpasswd_user_groups"` + AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"` + EmailDomains []string `flag:"email-domain" cfg:"email_domains"` + WhitelistDomains []string `flag:"whitelist-domain" cfg:"whitelist_domains"` + HtpasswdFile string `flag:"htpasswd-file" cfg:"htpasswd_file"` + HtpasswdUserGroups []string `flag:"htpasswd-user-group" cfg:"htpasswd_user_groups"` Cookie Cookie `cfg:",squash"` Session SessionOptions `cfg:",squash"` @@ -66,6 +49,8 @@ type Options struct { Server Server `cfg:",internal"` MetricsServer Server `cfg:",internal"` + Providers Providers `cfg:",internal"` + SkipAuthRegex []string `flag:"skip-auth-regex" cfg:"skip_auth_regex"` SkipAuthRoutes []string `flag:"skip-auth-route" cfg:"skip_auth_routes"` SkipJwtBearerTokens bool `flag:"skip-jwt-bearer-tokens" cfg:"skip_jwt_bearer_tokens"` @@ -74,34 +59,7 @@ type Options struct { SSLInsecureSkipVerify bool `flag:"ssl-insecure-skip-verify" cfg:"ssl_insecure_skip_verify"` SkipAuthPreflight bool `flag:"skip-auth-preflight" cfg:"skip_auth_preflight"` - // These options allow for other providers besides Google, with - // potential overrides. - ProviderType string `flag:"provider" cfg:"provider"` - ProviderName string `flag:"provider-display-name" cfg:"provider_display_name"` - ProviderCAFiles []string `flag:"provider-ca-file" cfg:"provider_ca_files"` - OIDCIssuerURL string `flag:"oidc-issuer-url" cfg:"oidc_issuer_url"` - InsecureOIDCAllowUnverifiedEmail bool `flag:"insecure-oidc-allow-unverified-email" cfg:"insecure_oidc_allow_unverified_email"` - InsecureOIDCSkipIssuerVerification bool `flag:"insecure-oidc-skip-issuer-verification" cfg:"insecure_oidc_skip_issuer_verification"` - SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery"` - OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url"` - OIDCEmailClaim string `flag:"oidc-email-claim" cfg:"oidc_email_claim"` - OIDCGroupsClaim string `flag:"oidc-groups-claim" cfg:"oidc_groups_claim"` - LoginURL string `flag:"login-url" cfg:"login_url"` - RedeemURL string `flag:"redeem-url" cfg:"redeem_url"` - ProfileURL string `flag:"profile-url" cfg:"profile_url"` - ProtectedResource string `flag:"resource" cfg:"resource"` - ValidateURL string `flag:"validate-url" cfg:"validate_url"` - Scope string `flag:"scope" cfg:"scope"` - Prompt string `flag:"prompt" cfg:"prompt"` - ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0 - UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"` - AllowedGroups []string `flag:"allowed-group" cfg:"allowed_groups"` - SignatureKey string `flag:"signature-key" cfg:"signature_key"` - AcrValues string `flag:"acr-values" cfg:"acr_values"` - JWTKey string `flag:"jwt-key" cfg:"jwt_key"` - JWTKeyFile string `flag:"jwt-key-file" cfg:"jwt_key_file"` - PubJWKURL string `flag:"pubjwk-url" cfg:"pubjwk_url"` GCPHealthChecks bool `flag:"gcp-healthchecks" cfg:"gcp_healthchecks"` // This is used for backwards compatibility for basic auth users @@ -135,24 +93,16 @@ func (o *Options) SetRealClientIPParser(s ipapi.RealClientIPParser) { o.realClie // NewOptions constructs a new Options with defaulted values func NewOptions() *Options { return &Options{ - ProxyPrefix: "/oauth2", - ProviderType: "google", - PingPath: "/ping", - RealClientIPHeader: "X-Real-IP", - ForceHTTPS: false, - Cookie: cookieDefaults(), - Session: sessionOptionsDefaults(), - Templates: templatesDefaults(), - AzureTenant: "common", - SkipAuthPreflight: false, - Prompt: "", // Change to "login" when ApprovalPrompt officially deprecated - ApprovalPrompt: "force", - InsecureOIDCAllowUnverifiedEmail: false, - SkipOIDCDiscovery: false, - Logging: loggingDefaults(), - UserIDClaim: providers.OIDCEmailClaim, // Deprecated: Use OIDCEmailClaim - OIDCEmailClaim: providers.OIDCEmailClaim, - OIDCGroupsClaim: providers.OIDCGroupsClaim, + ProxyPrefix: "/oauth2", + Providers: providerDefaults(), + PingPath: "/ping", + RealClientIPHeader: "X-Real-IP", + ForceHTTPS: false, + Cookie: cookieDefaults(), + Session: sessionOptionsDefaults(), + Templates: templatesDefaults(), + SkipAuthPreflight: false, + Logging: loggingDefaults(), } } @@ -175,23 +125,6 @@ func NewFlagSet() *pflag.FlagSet { flagSet.StringSlice("email-domain", []string{}, "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email") flagSet.StringSlice("whitelist-domain", []string{}, "allowed domains for redirection after authentication. Prefix domain with a . to allow subdomains (eg .example.com)") - flagSet.StringSlice("keycloak-group", []string{}, "restrict logins to members of these groups (may be given multiple times)") - flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.") - flagSet.String("bitbucket-team", "", "restrict logins to members of this team") - flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository") - flagSet.String("github-org", "", "restrict logins to members of this organisation") - flagSet.String("github-team", "", "restrict logins to members of this team") - flagSet.String("github-repo", "", "restrict logins to collaborators of this repository") - flagSet.String("github-token", "", "the token to use when verifying repository collaborators (must have push access to the repository)") - 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") flagSet.String("authenticated-emails-file", "", "authenticate against emails via file (one per line)") flagSet.String("htpasswd-file", "", "additionally authenticate against a htpasswd file. Entries must be created with \"htpasswd -B\" for bcrypt encryption") flagSet.StringSlice("htpasswd-user-group", []string{}, "the groups to be set on sessions for htpasswd users (may be given multiple times)") @@ -211,35 +144,9 @@ func NewFlagSet() *pflag.FlagSet { flagSet.Bool("redis-use-cluster", false, "Connect to redis cluster. Must set --redis-cluster-connection-urls to use this feature") flagSet.StringSlice("redis-cluster-connection-urls", []string{}, "List of Redis cluster connection URLs (eg redis://HOST[:PORT]). Used in conjunction with --redis-use-cluster") - flagSet.String("provider", "google", "OAuth provider") - flagSet.String("provider-display-name", "", "Provider display name") - flagSet.StringSlice("provider-ca-file", []string{}, "One or more paths to CA certificates that should be used when connecting to the provider. If not specified, the default Go trust sources are used instead.") - flagSet.String("oidc-issuer-url", "", "OpenID Connect issuer URL (ie: https://accounts.google.com)") - flagSet.Bool("insecure-oidc-allow-unverified-email", false, "Don't fail if an email address in an id_token is not verified") - flagSet.Bool("insecure-oidc-skip-issuer-verification", false, "Do not verify if issuer matches OIDC discovery URL") - flagSet.Bool("skip-oidc-discovery", false, "Skip OIDC discovery and use manually supplied Endpoints") - flagSet.String("oidc-jwks-url", "", "OpenID Connect JWKS URL (ie: https://www.googleapis.com/oauth2/v3/certs)") - flagSet.String("oidc-groups-claim", providers.OIDCGroupsClaim, "which OIDC claim contains the user groups") - flagSet.String("oidc-email-claim", providers.OIDCEmailClaim, "which OIDC claim contains the user's email") - flagSet.String("login-url", "", "Authentication endpoint") - flagSet.String("redeem-url", "", "Token redemption endpoint") - flagSet.String("profile-url", "", "Profile access endpoint") - flagSet.String("resource", "", "The resource that is protected (Azure AD only)") - flagSet.String("validate-url", "", "Access token validation endpoint") - flagSet.String("scope", "", "OAuth scope specification") - flagSet.String("prompt", "", "OIDC prompt") - flagSet.String("approval-prompt", "force", "OAuth approval_prompt") - flagSet.String("signature-key", "", "GAP-Signature request signature key (algorithm:secretkey)") - flagSet.String("acr-values", "", "acr values string: optional") - flagSet.String("jwt-key", "", "private key in PEM format used to sign JWT, so that you can say something like -jwt-key=\"${OAUTH2_PROXY_JWT_KEY}\": required by login.gov") - flagSet.String("jwt-key-file", "", "path to the private key file in PEM format used to sign the JWT so that you can say something like -jwt-key-file=/etc/ssl/private/jwt_signing_key.pem: required by login.gov") - flagSet.String("pubjwk-url", "", "JWK pubkey access endpoint: required by login.gov") flagSet.Bool("gcp-healthchecks", false, "Enable GCP/GKE healthcheck endpoints") - flagSet.String("user-id-claim", providers.OIDCEmailClaim, "(DEPRECATED for `oidc-email-claim`) which claim contains the user ID") - flagSet.StringSlice("allowed-group", []string{}, "restrict logins to members of this group (may be given multiple times)") - flagSet.AddFlagSet(cookieFlagSet()) flagSet.AddFlagSet(loggingFlagSet()) flagSet.AddFlagSet(templatesFlagSet()) diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go new file mode 100644 index 00000000..94a8e4c7 --- /dev/null +++ b/pkg/apis/options/providers.go @@ -0,0 +1,180 @@ +package options + +import "github.com/oauth2-proxy/oauth2-proxy/v7/providers" + +// Providers is a collection of definitions for providers. +type Providers []Provider + +// Provider holds all configuration for a single provider +type Provider struct { + // ClientID is the OAuth Client ID that is defined in the provider + // This value is required for all providers. + ClientID string `json:"clientID,omitempty"` + // ClientSecret is the OAuth Client Secret that is defined in the provider + // This value is required for all providers. + ClientSecret string `json:"clientSecret,omitempty"` + // ClientSecretFile is the name of the file + // containing the OAuth Client Secret, it will be used if ClientSecret is not set. + ClientSecretFile string `json:"clientSecretFile,omitempty"` + + // KeycloakConfig holds all configurations for Keycloak provider. + KeycloakConfig KeycloakOptions `json:"keycloakConfig,omitempty"` + // AzureConfig holds all configurations for Azure provider. + AzureConfig AzureOptions `json:"azureConfig,omitempty"` + // BitbucketConfig holds all configurations for Bitbucket provider. + BitbucketConfig BitbucketOptions `json:"bitbucketConfig,omitempty"` + // GitHubConfig holds all configurations for GitHubC provider. + GitHubConfig GitHubOptions `json:"githubConfig,omitempty"` + // GitLabConfig holds all configurations for GitLab provider. + GitLabConfig GitLabOptions `json:"gitlabConfig,omitempty"` + // GoogleConfig holds all configurations for Google provider. + GoogleConfig GoogleOptions `json:"googleConfig,omitempty"` + // OIDCConfig holds all configurations for OIDC provider + // or providers utilize OIDC configurations. + OIDCConfig OIDCOptions `json:"oidcConfig,omitempty"` + // LoginGovConfig holds all configurations for LoginGov provider. + LoginGovConfig LoginGovOptions `json:"loginGovConfig,omitempty"` + + // ID should be a unique identifier for the provider. + // This value is required for all providers. + ID string `json:"id,omitempty"` + // Type is the OAuth provider + // must be set from the supported providers group, + // otherwise 'Google' is set as default + Type string `json:"provider,omitempty"` + // Name is the providers display name + // if set, it will be shown to the users in the login page. + Name string `json:"name,omitempty"` + // CAFiles is a list of paths to CA certificates that should be used when connecting to the provider. + // If not specified, the default Go trust sources are used instead + CAFiles []string `json:"caFiles,omitempty"` + + // LoginURL is the authentication endpoint + LoginURL string `json:"loginURL,omitempty"` + // RedeemURL is the token redemption endpoint + RedeemURL string `json:"redeemURL,omitempty"` + // ProfileURL is the profile access endpoint + ProfileURL string `json:"profileURL,omitempty"` + // ProtectedResource is the resource that is protected (Azure AD only) + ProtectedResource string `json:"resource,omitempty"` + // ValidateURL is the access token validation endpoint + ValidateURL string `json:"validateURL,omitempty"` + // Scope is the OAuth scope specification + Scope string `json:"scope,omitempty"` + // Prompt is OIDC prompt + Prompt string `json:"prompt,omitempty"` + // ApprovalPrompt is the OAuth approval_prompt + // default is set to 'force' + ApprovalPrompt string `json:"approvalPrompt,omitempty"` + // AllowedGroups is a list of restrict logins to members of this group + AllowedGroups []string `json:"allowedGroups,omitempty"` + + // AcrValues is a string of acr values + AcrValues string `json:"acrValues,omitempty"` +} + +type KeycloakOptions struct { + // Group enables to restrict login to members of indicated group + Groups []string `json:"groups,omitempty"` +} + +type AzureOptions struct { + // Tenant directs to a tenant-specific or common (tenant-independent) endpoint + // Default value is 'commmon' + Tenant string `json:"tenant,omitempty"` +} + +type BitbucketOptions struct { + // Team sets restrict logins to members of this team + Team string `json:"team,omitempty"` + // Repository sets restrict logins to user with access to this repository + Repository string `json:"repository,omitempty"` +} + +type GitHubOptions struct { + // Org sets restrict logins to members of this organisation + Org string `json:"org,omitempty"` + // Team sets restrict logins to members of this team + Team string `json:"team,omitempty"` + // Repo sets restrict logins to collaborators of this repository + Repo string `json:"repo,omitempty"` + // Token is the token to use when verifying repository collaborators + // it must have push access to the repository + Token string `json:"token,omitempty"` + // Users allows users with these usernames to login + // even if they do not belong to the specified org and team or collaborators + Users []string `json:"users,omitempty"` +} + +type GitLabOptions struct { + // Group sets restrict logins to members of this group + Group []string `json:"group,omitempty"` + // Projects restricts logins to members of any of these projects + Projects []string `json:"projects,omitempty"` +} + +type GoogleOptions struct { + // Groups sets restrict logins to members of this google group + Groups []string `json:"group,omitempty"` + // AdminEmail is the google admin to impersonate for api calls + AdminEmail string `json:"adminEmail,omitempty"` + // ServiceAccountJSON is the path to the service account json credentials + ServiceAccountJSON string `json:"serviceAccountJson,omitempty"` +} + +type OIDCOptions struct { + // IssuerURL is the OpenID Connect issuer URL + // eg: https://accounts.google.com + IssuerURL string `json:"issuerURL,omitempty"` + // InsecureAllowUnverifiedEmail prevents failures if an email address in an id_token is not verified + // default set to 'false' + InsecureAllowUnverifiedEmail bool `json:"insecureAllowUnverifiedEmail,omitempty"` + // InsecureSkipIssuerVerification skips verification of ID token issuers. When false, ID Token Issuers must match the OIDC discovery URL + // default set to 'false' + InsecureSkipIssuerVerification bool `json:"insecureSkipIssuerVerification,omitempty"` + // SkipDiscovery allows to skip OIDC discovery and use manually supplied Endpoints + // default set to 'false' + SkipDiscovery bool `json:"skipDiscovery,omitempty"` + // JwksURL is the OpenID Connect JWKS URL + // eg: https://www.googleapis.com/oauth2/v3/certs + JwksURL string `json:"jwksURL,omitempty"` + // EmailClaim indicates which claim contains the user email, + // default set to 'email' + EmailClaim string `json:"emailClaim,omitempty"` + // GroupsClaim indicates which claim contains the user groups + // default set to 'groups' + GroupsClaim string `json:"groupsClaim,omitempty"` + // UserIDClaim indicates which claim contains the user ID + // default set to 'email' + UserIDClaim string `json:"userIDClaim,omitempty"` +} + +type LoginGovOptions struct { + // JWTKey is a private key in PEM format used to sign JWT, + JWTKey string `json:"jwtKey,omitempty"` + // JWTKeyFile is a path to the private key file in PEM format used to sign the JWT + JWTKeyFile string `json:"jwtKeyFile,omitempty"` + // PubJWKURL is the JWK pubkey access endpoint + PubJWKURL string `json:"pubjwkURL,omitempty"` +} + +func providerDefaults() Providers { + providers := Providers{ + { + Type: "google", + Prompt: "", // Change to "login" when ApprovalPrompt officially deprecated + ApprovalPrompt: "force", + AzureConfig: AzureOptions{ + Tenant: "common", + }, + OIDCConfig: OIDCOptions{ + InsecureAllowUnverifiedEmail: false, + SkipDiscovery: false, + UserIDClaim: providers.OIDCEmailClaim, // Deprecated: Use OIDCEmailClaim + EmailClaim: providers.OIDCEmailClaim, + GroupsClaim: providers.OIDCGroupsClaim, + }, + }, + } + return providers +} diff --git a/pkg/validation/options.go b/pkg/validation/options.go index e541e159..5e36f894 100644 --- a/pkg/validation/options.go +++ b/pkg/validation/options.go @@ -29,6 +29,7 @@ func Validate(o *options.Options) error { msgs = append(msgs, validateRedisSessionStore(o)...) msgs = append(msgs, prefixValues("injectRequestHeaders: ", validateHeaders(o.InjectRequestHeaders)...)...) msgs = append(msgs, prefixValues("injectResponseHeaders: ", validateHeaders(o.InjectResponseHeaders)...)...) + msgs = append(msgs, validateProviders(o)...) msgs = configureLogger(o.Logging, msgs) msgs = parseSignatureKey(o, msgs) @@ -39,8 +40,8 @@ func Validate(o *options.Options) error { TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } http.DefaultClient = &http.Client{Transport: insecureTransport} - } else if len(o.ProviderCAFiles) > 0 { - pool, err := util.GetCertPool(o.ProviderCAFiles) + } else if len(o.Providers[0].CAFiles) > 0 { + pool, err := util.GetCertPool(o.Providers[0].CAFiles) if err == nil { transport := http.DefaultTransport.(*http.Transport).Clone() transport.TLSClientConfig = &tls.Config{ @@ -54,38 +55,23 @@ func Validate(o *options.Options) error { } } - if o.ClientID == "" { - msgs = append(msgs, "missing setting: client-id") - } - // login.gov uses a signed JWT to authenticate, not a client-secret - if o.ProviderType != "login.gov" { - if o.ClientSecret == "" && o.ClientSecretFile == "" { - msgs = append(msgs, "missing setting: client-secret or client-secret-file") - } - if o.ClientSecret == "" && o.ClientSecretFile != "" { - _, err := ioutil.ReadFile(o.ClientSecretFile) - if err != nil { - msgs = append(msgs, "could not read client secret file: "+o.ClientSecretFile) - } - } - } if o.AuthenticatedEmailsFile == "" && len(o.EmailDomains) == 0 && o.HtpasswdFile == "" { msgs = append(msgs, "missing setting for email validation: email-domain or authenticated-emails-file required."+ "\n use email-domain=* to authorize all email addresses") } - if o.OIDCIssuerURL != "" { + if o.Providers[0].OIDCConfig.IssuerURL != "" { ctx := context.Background() - if o.InsecureOIDCSkipIssuerVerification && !o.SkipOIDCDiscovery { + if o.Providers[0].OIDCConfig.InsecureSkipIssuerVerification && !o.Providers[0].OIDCConfig.SkipDiscovery { // go-oidc doesn't let us pass bypass the issuer check this in the oidc.NewProvider call // (which uses discovery to get the URLs), so we'll do a quick check ourselves and if // we get the URLs, we'll just use the non-discovery path. logger.Printf("Performing OIDC Discovery...") - requestURL := strings.TrimSuffix(o.OIDCIssuerURL, "/") + "/.well-known/openid-configuration" + requestURL := strings.TrimSuffix(o.Providers[0].OIDCConfig.IssuerURL, "/") + "/.well-known/openid-configuration" body, err := requests.New(requestURL). WithContext(ctx). Do(). @@ -96,23 +82,23 @@ func Validate(o *options.Options) error { // Prefer manually configured URLs. It's a bit unclear // why you'd be doing discovery and also providing the URLs // explicitly though... - if o.LoginURL == "" { - o.LoginURL = body.Get("authorization_endpoint").MustString() + if o.Providers[0].LoginURL == "" { + o.Providers[0].LoginURL = body.Get("authorization_endpoint").MustString() } - if o.RedeemURL == "" { - o.RedeemURL = body.Get("token_endpoint").MustString() + if o.Providers[0].RedeemURL == "" { + o.Providers[0].RedeemURL = body.Get("token_endpoint").MustString() } - if o.OIDCJwksURL == "" { - o.OIDCJwksURL = body.Get("jwks_uri").MustString() + if o.Providers[0].OIDCConfig.JwksURL == "" { + o.Providers[0].OIDCConfig.JwksURL = body.Get("jwks_uri").MustString() } - if o.ProfileURL == "" { - o.ProfileURL = body.Get("userinfo_endpoint").MustString() + if o.Providers[0].ProfileURL == "" { + o.Providers[0].ProfileURL = body.Get("userinfo_endpoint").MustString() } - o.SkipOIDCDiscovery = true + o.Providers[0].OIDCConfig.SkipDiscovery = true } } @@ -120,42 +106,45 @@ func Validate(o *options.Options) error { // instead of metadata discovery if we enable -skip-oidc-discovery. // In this case we need to make sure the required endpoints for // the provider are configured. - if o.SkipOIDCDiscovery { - if o.LoginURL == "" { + if o.Providers[0].OIDCConfig.SkipDiscovery { + if o.Providers[0].LoginURL == "" { msgs = append(msgs, "missing setting: login-url") } - if o.RedeemURL == "" { + if o.Providers[0].RedeemURL == "" { msgs = append(msgs, "missing setting: redeem-url") } - if o.OIDCJwksURL == "" { + if o.Providers[0].OIDCConfig.JwksURL == "" { msgs = append(msgs, "missing setting: oidc-jwks-url") } - keySet := oidc.NewRemoteKeySet(ctx, o.OIDCJwksURL) - o.SetOIDCVerifier(oidc.NewVerifier(o.OIDCIssuerURL, keySet, &oidc.Config{ - ClientID: o.ClientID, - SkipIssuerCheck: o.InsecureOIDCSkipIssuerVerification, + keySet := oidc.NewRemoteKeySet(ctx, o.Providers[0].OIDCConfig.JwksURL) + o.SetOIDCVerifier(oidc.NewVerifier(o.Providers[0].OIDCConfig.IssuerURL, keySet, &oidc.Config{ + ClientID: o.Providers[0].ClientID, + SkipIssuerCheck: o.Providers[0].OIDCConfig.InsecureSkipIssuerVerification, })) } else { // Configure discoverable provider data. - provider, err := oidc.NewProvider(ctx, o.OIDCIssuerURL) + provider, err := oidc.NewProvider(ctx, o.Providers[0].OIDCConfig.IssuerURL) if err != nil { return err } o.SetOIDCVerifier(provider.Verifier(&oidc.Config{ - ClientID: o.ClientID, - SkipIssuerCheck: o.InsecureOIDCSkipIssuerVerification, + ClientID: o.Providers[0].ClientID, + SkipIssuerCheck: o.Providers[0].OIDCConfig.InsecureSkipIssuerVerification, })) - o.LoginURL = provider.Endpoint().AuthURL - o.RedeemURL = provider.Endpoint().TokenURL + o.Providers[0].LoginURL = provider.Endpoint().AuthURL + o.Providers[0].RedeemURL = provider.Endpoint().TokenURL } - if o.Scope == "" { - o.Scope = "openid email profile" + if o.Providers[0].Scope == "" { + o.Providers[0].Scope = "openid email profile" - if len(o.AllowedGroups) > 0 { - o.Scope += " groups" + if len(o.Providers[0].AllowedGroups) > 0 { + o.Providers[0].Scope += " groups" } } + if o.Providers[0].OIDCConfig.UserIDClaim == "" { + o.Providers[0].OIDCConfig.UserIDClaim = "email" + } } if o.SkipJwtBearerTokens { @@ -183,18 +172,6 @@ func Validate(o *options.Options) error { msgs = append(msgs, validateUpstreams(o.UpstreamServers)...) msgs = parseProviderInfo(o, msgs) - if len(o.GoogleGroups) > 0 || o.GoogleAdminEmail != "" || o.GoogleServiceAccountJSON != "" { - if len(o.GoogleGroups) < 1 { - msgs = append(msgs, "missing setting: google-group") - } - if o.GoogleAdminEmail == "" { - msgs = append(msgs, "missing setting: google-admin-email") - } - if o.GoogleServiceAccountJSON == "" { - msgs = append(msgs, "missing setting: google-service-account-json") - } - } - if o.ReverseProxy { parser, err := ip.GetRealClientIPParser(o.RealClientIPHeader) if err != nil { @@ -220,79 +197,79 @@ func Validate(o *options.Options) error { func parseProviderInfo(o *options.Options, msgs []string) []string { p := &providers.ProviderData{ - Scope: o.Scope, - ClientID: o.ClientID, - ClientSecret: o.ClientSecret, - ClientSecretFile: o.ClientSecretFile, - Prompt: o.Prompt, - ApprovalPrompt: o.ApprovalPrompt, - AcrValues: o.AcrValues, + Scope: o.Providers[0].Scope, + ClientID: o.Providers[0].ClientID, + ClientSecret: o.Providers[0].ClientSecret, + ClientSecretFile: o.Providers[0].ClientSecretFile, + Prompt: o.Providers[0].Prompt, + ApprovalPrompt: o.Providers[0].ApprovalPrompt, + AcrValues: o.Providers[0].AcrValues, } - p.LoginURL, msgs = parseURL(o.LoginURL, "login", msgs) - p.RedeemURL, msgs = parseURL(o.RedeemURL, "redeem", msgs) - p.ProfileURL, msgs = parseURL(o.ProfileURL, "profile", msgs) - p.ValidateURL, msgs = parseURL(o.ValidateURL, "validate", msgs) - p.ProtectedResource, msgs = parseURL(o.ProtectedResource, "resource", msgs) + p.LoginURL, msgs = parseURL(o.Providers[0].LoginURL, "login", msgs) + p.RedeemURL, msgs = parseURL(o.Providers[0].RedeemURL, "redeem", msgs) + p.ProfileURL, msgs = parseURL(o.Providers[0].ProfileURL, "profile", msgs) + p.ValidateURL, msgs = parseURL(o.Providers[0].ValidateURL, "validate", msgs) + p.ProtectedResource, msgs = parseURL(o.Providers[0].ProtectedResource, "resource", msgs) // Make the OIDC options available to all providers that support it - p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail - p.EmailClaim = o.OIDCEmailClaim - p.GroupsClaim = o.OIDCGroupsClaim + p.AllowUnverifiedEmail = o.Providers[0].OIDCConfig.InsecureAllowUnverifiedEmail + p.EmailClaim = o.Providers[0].OIDCConfig.EmailClaim + p.GroupsClaim = o.Providers[0].OIDCConfig.GroupsClaim p.Verifier = o.GetOIDCVerifier() // TODO (@NickMeves) - Remove This // Backwards Compatibility for Deprecated UserIDClaim option - if o.OIDCEmailClaim == providers.OIDCEmailClaim && - o.UserIDClaim != providers.OIDCEmailClaim { - p.EmailClaim = o.UserIDClaim + if o.Providers[0].OIDCConfig.EmailClaim == providers.OIDCEmailClaim && + o.Providers[0].OIDCConfig.UserIDClaim != providers.OIDCEmailClaim { + p.EmailClaim = o.Providers[0].OIDCConfig.UserIDClaim } - p.SetAllowedGroups(o.AllowedGroups) + p.SetAllowedGroups(o.Providers[0].AllowedGroups) - provider := providers.New(o.ProviderType, p) + provider := providers.New(o.Providers[0].Type, p) if provider == nil { - msgs = append(msgs, fmt.Sprintf("invalid setting: provider '%s' is not available", o.ProviderType)) + msgs = append(msgs, fmt.Sprintf("invalid setting: provider '%s' is not available", o.Providers[0].Type)) return msgs } o.SetProvider(provider) switch p := o.GetProvider().(type) { case *providers.AzureProvider: - p.Configure(o.AzureTenant) + p.Configure(o.Providers[0].AzureConfig.Tenant) case *providers.GitHubProvider: - p.SetOrgTeam(o.GitHubOrg, o.GitHubTeam) - p.SetRepo(o.GitHubRepo, o.GitHubToken) - p.SetUsers(o.GitHubUsers) + p.SetOrgTeam(o.Providers[0].GitHubConfig.Org, o.Providers[0].GitHubConfig.Team) + p.SetRepo(o.Providers[0].GitHubConfig.Repo, o.Providers[0].GitHubConfig.Token) + p.SetUsers(o.Providers[0].GitHubConfig.Users) case *providers.KeycloakProvider: // Backwards compatibility with `--keycloak-group` option - if len(o.KeycloakGroups) > 0 { - p.SetAllowedGroups(o.KeycloakGroups) + if len(o.Providers[0].KeycloakConfig.Groups) > 0 { + p.SetAllowedGroups(o.Providers[0].KeycloakConfig.Groups) } case *providers.GoogleProvider: - if o.GoogleServiceAccountJSON != "" { - file, err := os.Open(o.GoogleServiceAccountJSON) + if o.Providers[0].GoogleConfig.ServiceAccountJSON != "" { + file, err := os.Open(o.Providers[0].GoogleConfig.ServiceAccountJSON) if err != nil { - msgs = append(msgs, "invalid Google credentials file: "+o.GoogleServiceAccountJSON) + msgs = append(msgs, "invalid Google credentials file: "+o.Providers[0].GoogleConfig.ServiceAccountJSON) } else { - groups := o.AllowedGroups + groups := o.Providers[0].AllowedGroups // Backwards compatibility with `--google-group` option - if len(o.GoogleGroups) > 0 { - groups = o.GoogleGroups + if len(o.Providers[0].GoogleConfig.Groups) > 0 { + groups = o.Providers[0].GoogleConfig.Groups p.SetAllowedGroups(groups) } - p.SetGroupRestriction(groups, o.GoogleAdminEmail, file) + p.SetGroupRestriction(groups, o.Providers[0].GoogleConfig.AdminEmail, file) } } case *providers.BitbucketProvider: - p.SetTeam(o.BitbucketTeam) - p.SetRepository(o.BitbucketRepository) + p.SetTeam(o.Providers[0].BitbucketConfig.Team) + p.SetRepository(o.Providers[0].BitbucketConfig.Repository) case *providers.OIDCProvider: if p.Verifier == nil { msgs = append(msgs, "oidc provider requires an oidc issuer URL") } case *providers.GitLabProvider: - p.Groups = o.GitLabGroup - err := p.AddProjects(o.GitlabProjects) + p.Groups = o.Providers[0].GitLabConfig.Group + err := p.AddProjects(o.Providers[0].GitLabConfig.Projects) if err != nil { msgs = append(msgs, "failed to setup gitlab project access level") } @@ -308,7 +285,7 @@ func parseProviderInfo(o *options.Options, msgs []string) []string { msgs = append(msgs, "failed to initialize oidc provider for gitlab.com") } else { p.Verifier = provider.Verifier(&oidc.Config{ - ClientID: o.ClientID, + ClientID: o.Providers[0].ClientID, }) p.LoginURL, msgs = parseURL(provider.Endpoint().AuthURL, "login", msgs) @@ -316,31 +293,31 @@ func parseProviderInfo(o *options.Options, msgs []string) []string { } } case *providers.LoginGovProvider: - p.PubJWKURL, msgs = parseURL(o.PubJWKURL, "pubjwk", msgs) + p.PubJWKURL, msgs = parseURL(o.Providers[0].LoginGovConfig.PubJWKURL, "pubjwk", msgs) // JWT key can be supplied via env variable or file in the filesystem, but not both. switch { - case o.JWTKey != "" && o.JWTKeyFile != "": + case o.Providers[0].LoginGovConfig.JWTKey != "" && o.Providers[0].LoginGovConfig.JWTKeyFile != "": msgs = append(msgs, "cannot set both jwt-key and jwt-key-file options") - case o.JWTKey == "" && o.JWTKeyFile == "": + case o.Providers[0].LoginGovConfig.JWTKey == "" && o.Providers[0].LoginGovConfig.JWTKeyFile == "": msgs = append(msgs, "login.gov provider requires a private key for signing JWTs") - case o.JWTKey != "": + case o.Providers[0].LoginGovConfig.JWTKey != "": // The JWT Key is in the commandline argument - signKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(o.JWTKey)) + signKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(o.Providers[0].LoginGovConfig.JWTKey)) if err != nil { msgs = append(msgs, "could not parse RSA Private Key PEM") } else { p.JWTKey = signKey } - case o.JWTKeyFile != "": + case o.Providers[0].LoginGovConfig.JWTKeyFile != "": // The JWT key is in the filesystem - keyData, err := ioutil.ReadFile(o.JWTKeyFile) + keyData, err := ioutil.ReadFile(o.Providers[0].LoginGovConfig.JWTKeyFile) if err != nil { - msgs = append(msgs, "could not read key file: "+o.JWTKeyFile) + msgs = append(msgs, "could not read key file: "+o.Providers[0].LoginGovConfig.JWTKeyFile) } signKey, err := jwt.ParseRSAPrivateKeyFromPEM(keyData) if err != nil { - msgs = append(msgs, "could not parse private key from PEM file:"+o.JWTKeyFile) + msgs = append(msgs, "could not parse private key from PEM file:"+o.Providers[0].LoginGovConfig.JWTKeyFile) } else { p.JWTKey = signKey } diff --git a/pkg/validation/options_test.go b/pkg/validation/options_test.go index e19c6991..5f9e3716 100644 --- a/pkg/validation/options_test.go +++ b/pkg/validation/options_test.go @@ -17,6 +17,7 @@ const ( cookieSecret = "secretthirtytwobytes+abcdefghijk" clientID = "bazquux" clientSecret = "xyzzyplugh" + providerID = "providerID" ) func testOptions() *options.Options { @@ -27,8 +28,9 @@ func testOptions() *options.Options { URI: "http://127.0.0.1:8080/", }) o.Cookie.Secret = cookieSecret - o.ClientID = clientID - o.ClientSecret = clientSecret + o.Providers[0].ID = providerID + o.Providers[0].ClientID = clientID + o.Providers[0].ClientSecret = clientSecret o.EmailDomains = []string{"*"} return o } @@ -48,7 +50,8 @@ func TestNewOptions(t *testing.T) { expected := errorMsg([]string{ "missing setting: cookie-secret", - "missing setting: client-id", + "provider has empty id: ids are required for all providers", + "provider missing setting: client-id", "missing setting: client-secret or client-secret-file"}) assert.Equal(t, expected, err.Error()) } @@ -56,8 +59,9 @@ func TestNewOptions(t *testing.T) { func TestClientSecretFileOptionFails(t *testing.T) { o := options.NewOptions() o.Cookie.Secret = cookieSecret - o.ClientID = clientID - o.ClientSecretFile = clientSecret + o.Providers[0].ID = providerID + o.Providers[0].ClientID = clientID + o.Providers[0].ClientSecretFile = clientSecret o.EmailDomains = []string{"*"} err := Validate(o) assert.NotEqual(t, nil, err) @@ -93,8 +97,9 @@ func TestClientSecretFileOption(t *testing.T) { o := options.NewOptions() o.Cookie.Secret = cookieSecret - o.ClientID = clientID - o.ClientSecretFile = clientSecretFileName + o.Providers[0].ID = providerID + o.Providers[0].ClientID = clientID + o.Providers[0].ClientSecretFile = clientSecretFileName o.EmailDomains = []string{"*"} err = Validate(o) assert.Equal(t, nil, err) @@ -110,7 +115,7 @@ func TestClientSecretFileOption(t *testing.T) { func TestGoogleGroupOptions(t *testing.T) { o := testOptions() - o.GoogleGroups = []string{"googlegroup"} + o.Providers[0].GoogleConfig.Groups = []string{"googlegroup"} err := Validate(o) assert.NotEqual(t, nil, err) @@ -122,9 +127,9 @@ func TestGoogleGroupOptions(t *testing.T) { func TestGoogleGroupInvalidFile(t *testing.T) { o := testOptions() - o.GoogleGroups = []string{"test_group"} - o.GoogleAdminEmail = "admin@example.com" - o.GoogleServiceAccountJSON = "file_doesnt_exist.json" + o.Providers[0].GoogleConfig.Groups = []string{"test_group"} + o.Providers[0].GoogleConfig.AdminEmail = "admin@example.com" + o.Providers[0].GoogleConfig.ServiceAccountJSON = "file_doesnt_exist.json" err := Validate(o) assert.NotEqual(t, nil, err) @@ -225,17 +230,17 @@ func TestValidateSignatureKeyUnsupportedAlgorithm(t *testing.T) { func TestSkipOIDCDiscovery(t *testing.T) { o := testOptions() - o.ProviderType = "oidc" - o.OIDCIssuerURL = "https://login.microsoftonline.com/fabrikamb2c.onmicrosoft.com/v2.0/" - o.SkipOIDCDiscovery = true + o.Providers[0].Type = "oidc" + o.Providers[0].OIDCConfig.IssuerURL = "https://login.microsoftonline.com/fabrikamb2c.onmicrosoft.com/v2.0/" + o.Providers[0].OIDCConfig.SkipDiscovery = true err := Validate(o) assert.Equal(t, "invalid configuration:\n"+ " missing setting: login-url\n missing setting: redeem-url\n missing setting: oidc-jwks-url", err.Error()) - o.LoginURL = "https://login.microsoftonline.com/fabrikamb2c.onmicrosoft.com/oauth2/v2.0/authorize?p=b2c_1_sign_in" - o.RedeemURL = "https://login.microsoftonline.com/fabrikamb2c.onmicrosoft.com/oauth2/v2.0/token?p=b2c_1_sign_in" - o.OIDCJwksURL = "https://login.microsoftonline.com/fabrikamb2c.onmicrosoft.com/discovery/v2.0/keys" + o.Providers[0].LoginURL = "https://login.microsoftonline.com/fabrikamb2c.onmicrosoft.com/oauth2/v2.0/authorize?p=b2c_1_sign_in" + o.Providers[0].RedeemURL = "https://login.microsoftonline.com/fabrikamb2c.onmicrosoft.com/oauth2/v2.0/token?p=b2c_1_sign_in" + o.Providers[0].OIDCConfig.JwksURL = "https://login.microsoftonline.com/fabrikamb2c.onmicrosoft.com/discovery/v2.0/keys" assert.Equal(t, nil, Validate(o)) } @@ -292,7 +297,7 @@ func TestProviderCAFilesError(t *testing.T) { assert.NoError(t, os.Remove(file.Name())) o := testOptions() - o.ProviderCAFiles = append(o.ProviderCAFiles, file.Name()) + o.Providers[0].CAFiles = append(o.Providers[0].CAFiles, file.Name()) err = Validate(o) assert.Error(t, err) assert.Contains(t, err.Error(), "unable to load provider CA file(s)") diff --git a/pkg/validation/providers.go b/pkg/validation/providers.go new file mode 100644 index 00000000..489f94d0 --- /dev/null +++ b/pkg/validation/providers.go @@ -0,0 +1,83 @@ +package validation + +import ( + "fmt" + "io/ioutil" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" +) + +// validateProviders is the initial validation migration for multiple providrers +// It currently includes only logic that can verify the providers one by one and does not break the valdation pipe +func validateProviders(o *options.Options) []string { + msgs := []string{} + + // validate general multiple provider configuration + if len(o.Providers) == 0 { + msgs = append(msgs, "at least one provider has to be defined") + } + if o.SkipProviderButton && len(o.Providers) > 1 { + msgs = append(msgs, "SkipProviderButton and multiple providers are mutually exclusive") + } + + providerIDs := make(map[string]struct{}) + + for _, provider := range o.Providers { + msgs = append(msgs, validateProvider(provider, providerIDs)...) + } + + return msgs +} + +func validateProvider(provider options.Provider, providerIDs map[string]struct{}) []string { + msgs := []string{} + + if provider.ID == "" { + msgs = append(msgs, "provider has empty id: ids are required for all providers") + } + + // Ensure provider IDs are unique + if _, ok := providerIDs[provider.ID]; ok { + msgs = append(msgs, fmt.Sprintf("multiple providers found with id %s: provider ids must be unique", provider.ID)) + } + providerIDs[provider.ID] = struct{}{} + + if provider.ClientID == "" { + msgs = append(msgs, "provider missing setting: client-id") + } + + // login.gov uses a signed JWT to authenticate, not a client-secret + if provider.Type != "login.gov" { + if provider.ClientSecret == "" && provider.ClientSecretFile == "" { + msgs = append(msgs, "missing setting: client-secret or client-secret-file") + } + if provider.ClientSecret == "" && provider.ClientSecretFile != "" { + _, err := ioutil.ReadFile(provider.ClientSecretFile) + if err != nil { + msgs = append(msgs, "could not read client secret file: "+provider.ClientSecretFile) + } + } + } + + msgs = append(msgs, validateGoogleConfig(provider)...) + + return msgs +} + +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") + } + } + return msgs +} diff --git a/pkg/validation/providers_test.go b/pkg/validation/providers_test.go new file mode 100644 index 00000000..48317040 --- /dev/null +++ b/pkg/validation/providers_test.go @@ -0,0 +1,84 @@ +package validation + +import ( + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" +) + +var _ = Describe("Providers", func() { + type validateProvidersTableInput struct { + options *options.Options + errStrings []string + } + + validProvider := options.Provider{ + ID: "ProviderID", + ClientID: "ClientID", + ClientSecret: "ClientSecret", + } + + validLoginGovProvider := options.Provider{ + Type: "login.gov", + ID: "ProviderIDLoginGov", + ClientID: "ClientID", + ClientSecret: "ClientSecret", + } + + missingIDProvider := options.Provider{ + ClientID: "ClientID", + ClientSecret: "ClientSecret", + } + + missingProvider := "at least one provider has to be defined" + emptyIDMsg := "provider has empty id: ids are required for all providers" + duplicateProviderIDMsg := "multiple providers found with id ProviderID: provider ids must be unique" + skipButtonAndMultipleProvidersMsg := "SkipProviderButton and multiple providers are mutually exclusive" + + DescribeTable("validateProviders", + func(o *validateProvidersTableInput) { + Expect(validateProviders(o.options)).To(ConsistOf(o.errStrings)) + }, + Entry("with no providers", &validateProvidersTableInput{ + options: &options.Options{}, + errStrings: []string{missingProvider}, + }), + Entry("with valid providers", &validateProvidersTableInput{ + options: &options.Options{ + Providers: options.Providers{ + validProvider, + validLoginGovProvider, + }, + }, + errStrings: []string{}, + }), + Entry("with an empty providerID", &validateProvidersTableInput{ + options: &options.Options{ + Providers: options.Providers{ + missingIDProvider, + }, + }, + errStrings: []string{emptyIDMsg}, + }), + Entry("with same providerID", &validateProvidersTableInput{ + options: &options.Options{ + Providers: options.Providers{ + validProvider, + validProvider, + }, + }, + errStrings: []string{duplicateProviderIDMsg}, + }), + Entry("with multiple providers and skip provider button", &validateProvidersTableInput{ + options: &options.Options{ + SkipProviderButton: true, + Providers: options.Providers{ + validProvider, + validLoginGovProvider, + }, + }, + errStrings: []string{skipButtonAndMultipleProvidersMsg}, + }), + ) +})