diff --git a/.vscode/launch.json b/.vscode/launch.json index 36c7fb09..f63b3b9e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,18 @@ { "version": "0.2.0", "configurations": [ + { + "name": "Config conversion", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}", + "args": [ + "--convert-config-to-alpha", + "--config", + "contrib/local-environment/oauth2-proxy.cfg" + ] + }, { "name": "OAuth2 Proxy for Dex", "type": "go", @@ -8,7 +20,8 @@ "mode": "auto", "program": "${workspaceFolder}", "args": [ - "--config", "contrib/local-environment/oauth2-proxy.cfg" + "--config", + "contrib/local-environment/oauth2-proxy.cfg" ] }, { @@ -18,7 +31,8 @@ "mode": "auto", "program": "${workspaceFolder}", "args": [ - "--config", "contrib/local-environment/oauth2-proxy-keycloak.cfg" + "--config", + "contrib/local-environment/oauth2-proxy-keycloak.cfg" ] }, { @@ -28,8 +42,10 @@ "mode": "auto", "program": "${workspaceFolder}", "args": [ - "--config", "contrib/local-environment/oauth2-proxy-alpha-config.cfg", - "--alpha-config", "contrib/local-environment/oauth2-proxy-alpha-config.yaml" + "--config", + "contrib/local-environment/oauth2-proxy-alpha-config.cfg", + "--alpha-config", + "contrib/local-environment/oauth2-proxy-alpha-config.yaml" ] } ] diff --git a/contrib/local-environment/oauth2-proxy-alpha-config.cfg b/contrib/local-environment/oauth2-proxy-alpha-config.cfg index c913ec4f..1a82f681 100644 --- a/contrib/local-environment/oauth2-proxy-alpha-config.cfg +++ b/contrib/local-environment/oauth2-proxy-alpha-config.cfg @@ -1,4 +1,2 @@ -cookie_secret="OQINaROshtE9TcZkNAm-5Zs2Pv3xaWytBmc5W7sPX7w=" email_domains="example.com" -cookie_secure="false" redirect_url="http://oauth2-proxy.localtest.me:4180/oauth2/callback" diff --git a/contrib/local-environment/oauth2-proxy-alpha-config.yaml b/contrib/local-environment/oauth2-proxy-alpha-config.yaml index e423db98..5a99e6a8 100644 --- a/contrib/local-environment/oauth2-proxy-alpha-config.yaml +++ b/contrib/local-environment/oauth2-proxy-alpha-config.yaml @@ -1,5 +1,15 @@ server: bindAddress: "0.0.0.0:4180" +cookie: + secret: OQINaROshtE9TcZkNAm-5Zs2Pv3xaWytBmc5W7sPX7w= + secure: false +providers: + - id: oidc + provider: oidc + clientSecret: b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK + clientID: oauth2-proxy + oidcConfig: + issuerURL: http://dex.localtest.me:5556/dex upstreamConfig: upstreams: - id: httpbin @@ -14,10 +24,3 @@ injectRequestHeaders: values: - claimSource: claim: email -providers: - - id: oidc - provider: oidc - clientSecret: b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK - clientID: oauth2-proxy - oidcConfig: - issuerURL: http://dex.localtest.me:5556/dex diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md index 680741ba..3db7e0ae 100644 --- a/docs/docs/configuration/alpha_config.md +++ b/docs/docs/configuration/alpha_config.md @@ -228,14 +228,14 @@ OAuth2 Proxy session fields. The following flags/options and their respective environment variables are no longer available when using alpha configuration: - +### Legacy Upstream options - `flush-interval`/`flush_interval` - `pass-host-header`/`pass_host_header` - `proxy-websockets`/`proxy_websockets` - `ssl-upstream-insecure-skip-verify`/`ssl_upstream_insecure_skip_verify` - `upstream`/`upstreams` - +### Legacy Headers options - `pass-basic-auth`/`pass_basic_auth` - `pass-access-token`/`pass_access_token` - `pass-user-headers`/`pass_user_headers` @@ -247,7 +247,7 @@ longer available when using alpha configuration: - `basic-auth-password`/`basic_auth_password` - `skip-auth-strip-headers`/`skip_auth_strip_headers` - +### Legacy Provider options - `client-id`/`client_id` - `client-secret`/`client_secret`, and `client-secret-file`/`client_secret_file` - `provider` @@ -269,6 +269,22 @@ longer available when using alpha configuration: - `jwt-key-file`/`jwt_key_file` - `pubjwk-url`/`pubjwk_url` +### Legacy Cookie options +- `cookie-name`/`cookie_name` +- `cookie-name`/`cookie_name` +- `cookie-secret`/`cookie_secret` +- `cookie-secret-file`/`cookie_secret_file` +- `cookie-domain`/`cookie_domains` +- `cookie-path`/`cookie_path` +- `cookie-expire`/`cookie_expire` +- `cookie-refresh`/`cookie_refresh` +- `cookie-secure`/`cookie_secure` +- `cookie-httponly`/`cookie_httponly` +- `cookie-samesite`/`cookie_samesite` +- `cookie-csrf-per-request`/`cookie_csrf_per_request` +- `cookie-csrf-per-request-limit`/`cookie_csrf_per_request_limit` +- `cookie-csrf-expire`/`cookie_csrf_expire` + and all provider-specific options, i.e. any option whose name includes `oidc`, `azure`, `bitbucket`, `github`, `gitlab`, `google` or `keycloak`. Attempting to use any of these options via flags or via config when `--alpha-config` is @@ -311,6 +327,7 @@ They may change between releases without notice. | `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 your provider. **Multiple-providers is not
yet working.** [This feature is tracked in
#925](https://github.com/oauth2-proxy/oauth2-proxy/issues/926) | +| `cookie` | _[Cookie](#cookie)_ | Cookie is used to configure the cookies used by OAuth2 Proxy.
This includes session and CSRF cookies. | ### AzureOptions @@ -346,6 +363,28 @@ ClaimSource allows loading a header value from a claim within the session | `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. | +### Cookie + +(**Appears on:** [AlphaOptions](#alphaoptions)) + +Cookie contains configuration options relating session and CSRF cookies + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `name` | _string_ | Name is the name of the cookie | +| `secret` | _string_ | Secret is the secret used to encrypt/sign the cookie value | +| `secretFile` | _string_ | SecretFile is a file containing the secret used to encrypt/sign the cookie value
instead of specifying it directly in the config. Secret takes precedence over SecretFile | +| `domains` | _[]string_ | Domains is a list of domains for which the cookie is valid | +| `path` | _string_ | Path is the path for which the cookie is valid | +| `expire` | _duration_ | Expire is the duration before the cookie expires | +| `refresh` | _duration_ | Refresh is the duration after which the cookie is refreshable | +| `secure` | _bool_ | Secure indicates whether the cookie is only sent over HTTPS | +| `httpOnly` | _bool_ | HTTPOnly indicates whether the cookie is inaccessible to JavaScript | +| `sameSite` | _string_ | SameSite sets the SameSite attribute on the cookie | +| `csrfPerRequest` | _bool_ | CSRFPerRequest indicates whether a unique CSRF token is generated for each request
Enables parallel requests from clients (e.g., multiple tabs) | +| `csrfPerRequestLimit` | _int_ | CSRFPerRequestLimit sets a limit on the number of valid CSRF tokens when CSRFPerRequest is enabled
Used to prevent unbounded memory growth from storing too many tokens | +| `csrfExpire` | _duration_ | CSRFExpire sets the duration before a CSRF token expires | + ### GitHubOptions (**Appears on:** [Provider](#provider)) diff --git a/docs/docs/configuration/alpha_config.md.tmpl b/docs/docs/configuration/alpha_config.md.tmpl index e7982055..d8002205 100644 --- a/docs/docs/configuration/alpha_config.md.tmpl +++ b/docs/docs/configuration/alpha_config.md.tmpl @@ -228,14 +228,14 @@ OAuth2 Proxy session fields. The following flags/options and their respective environment variables are no longer available when using alpha configuration: - +### Legacy Upstream options - `flush-interval`/`flush_interval` - `pass-host-header`/`pass_host_header` - `proxy-websockets`/`proxy_websockets` - `ssl-upstream-insecure-skip-verify`/`ssl_upstream_insecure_skip_verify` - `upstream`/`upstreams` - +### Legacy Headers options - `pass-basic-auth`/`pass_basic_auth` - `pass-access-token`/`pass_access_token` - `pass-user-headers`/`pass_user_headers` @@ -247,7 +247,7 @@ longer available when using alpha configuration: - `basic-auth-password`/`basic_auth_password` - `skip-auth-strip-headers`/`skip_auth_strip_headers` - +### Legacy Provider options - `client-id`/`client_id` - `client-secret`/`client_secret`, and `client-secret-file`/`client_secret_file` - `provider` @@ -269,6 +269,22 @@ longer available when using alpha configuration: - `jwt-key-file`/`jwt_key_file` - `pubjwk-url`/`pubjwk_url` +### Legacy Cookie options +- `cookie-name`/`cookie_name` +- `cookie-name`/`cookie_name` +- `cookie-secret`/`cookie_secret` +- `cookie-secret-file`/`cookie_secret_file` +- `cookie-domain`/`cookie_domains` +- `cookie-path`/`cookie_path` +- `cookie-expire`/`cookie_expire` +- `cookie-refresh`/`cookie_refresh` +- `cookie-secure`/`cookie_secure` +- `cookie-httponly`/`cookie_httponly` +- `cookie-samesite`/`cookie_samesite` +- `cookie-csrf-per-request`/`cookie_csrf_per_request` +- `cookie-csrf-per-request-limit`/`cookie_csrf_per_request_limit` +- `cookie-csrf-expire`/`cookie_csrf_expire` + and all provider-specific options, i.e. any option whose name includes `oidc`, `azure`, `bitbucket`, `github`, `gitlab`, `google` or `keycloak`. Attempting to use any of these options via flags or via config when `--alpha-config` is diff --git a/main.go b/main.go index ba970679..5dc1de9a 100644 --- a/main.go +++ b/main.go @@ -111,7 +111,7 @@ func loadLegacyOptions(config string, extraFlags *pflag.FlagSet, args []string) legacyOpts := options.NewLegacyOptions() if err := options.Load(config, optionsFlagSet, legacyOpts); err != nil { - return nil, fmt.Errorf("failed to load config: %v", err) + return nil, fmt.Errorf("failed to load legacy config: %v", err) } opts, err := legacyOpts.ToOptions() @@ -164,6 +164,28 @@ func loadOptions(config string, extraFlags *pflag.FlagSet, args []string) (*opti func printConvertedConfig(opts *options.Options) error { alphaConfig := options.NewAlphaOptions(opts) + if len(alphaConfig.Providers) == 1 { + providerType := alphaConfig.Providers[0].Type + + if providerType != options.ADFSProvider { + alphaConfig.Providers[0].ADFSConfig = options.ADFSOptions{} + } + + if providerType != options.AzureProvider { + alphaConfig.Providers[0].AzureConfig = options.AzureOptions{} + } + + if providerType != options.GoogleProvider { + alphaConfig.Providers[0].GoogleConfig = options.GoogleOptions{} + } + + if providerType != options.MicrosoftEntraIDProvider { + alphaConfig.Providers[0].MicrosoftEntraIDConfig = options.MicrosoftEntraIDOptions{} + } + } else { + return fmt.Errorf("only single provider conversion is supported") + } + // Generic interface for loading arbitrary yaml structure var buffer map[string]interface{} diff --git a/main_test.go b/main_test.go index c7c7057d..88482d31 100644 --- a/main_test.go +++ b/main_test.go @@ -16,18 +16,24 @@ import ( var _ = Describe("Configuration Loading Suite", func() { // For comparing the full configuration differences of our structs we need to increase the gomega limits - format.MaxLength = 50000 + format.MaxLength = 0 format.MaxDepth = 10 const testLegacyConfig = ` http_address="127.0.0.1:4180" upstreams="http://httpbin" + set_basic_auth="true" basic_auth_password="c3VwZXItc2VjcmV0LXBhc3N3b3Jk" + client_id="oauth2-proxy" client_secret="b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK" + google_admin_email="admin@example.com" google_target_principal="principal" + +cookie_secret="OQINaROshtE9TcZkNAm-5Zs2Pv3xaWytBmc5W7sPX7w=" +cookie_secure="false" ` const testAlphaConfig = ` @@ -81,6 +87,9 @@ injectResponseHeaders: value: c3VwZXItc2VjcmV0LXBhc3N3b3Jk server: bindAddress: "127.0.0.1:4180" +cookie: + secure: false + secret: "OQINaROshtE9TcZkNAm-5Zs2Pv3xaWytBmc5W7sPX7w=" providers: - id: google=oauth2-proxy provider: google @@ -107,10 +116,7 @@ providers: ` const testCoreConfig = ` -cookie_secret="OQINaROshtE9TcZkNAm-5Zs2Pv3xaWytBmc5W7sPX7w=" email_domains="example.com" -cookie_secure="false" - redirect_url="http://localhost:4180/oauth2/callback" ` @@ -120,7 +126,7 @@ redirect_url="http://localhost:4180/oauth2/callback" opts.Cookie.Secret = "OQINaROshtE9TcZkNAm-5Zs2Pv3xaWytBmc5W7sPX7w=" opts.EmailDomains = []string{"example.com"} - opts.Cookie.Secure = false + opts.Cookie.Secure = ptr.To(false) opts.RawRedirectURL = "http://localhost:4180/oauth2/callback" opts.UpstreamServers = options.UpstreamConfig{ @@ -278,7 +284,7 @@ redirect_url="http://localhost:4180/oauth2/callback" Entry("with bad legacy configuration", loadConfigurationTableInput{ configContent: testCoreConfig + "unknown_field=\"something\"", expectedOptions: func() *options.Options { return nil }, - expectedErr: errors.New("failed to load legacy options: failed to load config: error unmarshalling config: decoding failed due to the following error(s):\n\n'' has invalid keys: unknown_field"), + expectedErr: errors.New("failed to load legacy options: failed to load legacy config: error unmarshalling config: decoding failed due to the following error(s):\n\n'' has invalid keys: unknown_field"), }), Entry("with bad alpha configuration", loadConfigurationTableInput{ configContent: testCoreConfig, @@ -290,7 +296,7 @@ redirect_url="http://localhost:4180/oauth2/callback" configContent: testCoreConfig + "unknown_field=\"something\"", alphaConfigContent: testAlphaConfig, expectedOptions: func() *options.Options { return nil }, - expectedErr: errors.New("failed to load legacy options: failed to load config: error unmarshalling config: decoding failed due to the following error(s):\n\n'' has invalid keys: unknown_field"), + expectedErr: errors.New("failed to load legacy options: failed to load legacy config: error unmarshalling config: decoding failed due to the following error(s):\n\n'' has invalid keys: unknown_field"), }), ) diff --git a/oauthproxy.go b/oauthproxy.go index 3efe66fd..803d4b30 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -30,6 +30,7 @@ import ( "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/proxyhttp" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/version" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/ip" @@ -1115,7 +1116,7 @@ func (p *OAuthProxy) getOAuthRedirectURI(req *http.Request) string { // If CookieSecure is true, return `https` no matter what // Not all reverse proxies set X-Forwarded-Proto - if p.CookieOptions.Secure { + if ptr.Deref(p.CookieOptions.Secure, options.DefaultCookieSecure) { rd.Scheme = schemeHTTPS } return rd.String() diff --git a/oauthproxy_test.go b/oauthproxy_test.go index e06f50e9..cb923cee 100644 --- a/oauthproxy_test.go +++ b/oauthproxy_test.go @@ -208,7 +208,7 @@ func TestBasicAuthPassword(t *testing.T) { }, } - opts.Cookie.Secure = false + opts.Cookie.Secure = ptr.To(false) opts.InjectRequestHeaders = []options.Header{ { Name: "Authorization", @@ -363,7 +363,7 @@ func NewPassAccessTokenTest(opts PassAccessTokenTestOptions) (*PassAccessTokenTe patt.opts.UpstreamServers.Upstreams = append(patt.opts.UpstreamServers.Upstreams, opts.ProxyUpstream) } - patt.opts.Cookie.Secure = false + patt.opts.Cookie.Secure = ptr.To(false) if opts.PassAccessToken { patt.opts.InjectRequestHeaders = []options.Header{ { @@ -2126,6 +2126,8 @@ func baseTestOptions() *options.Options { }, } + opts.EnsureDefaults() + return opts } @@ -3547,7 +3549,7 @@ func TestGetOAuthRedirectURI(t *testing.T) { { name: "redirect with http schema", setupOpts: func(baseOpts *options.Options) *options.Options { - baseOpts.Cookie.Secure = false + baseOpts.Cookie.Secure = ptr.To(false) return baseOpts }, req: &http.Request{ diff --git a/pkg/apis/options/alpha_options.go b/pkg/apis/options/alpha_options.go index 33daf17f..977bdb18 100644 --- a/pkg/apis/options/alpha_options.go +++ b/pkg/apis/options/alpha_options.go @@ -45,6 +45,10 @@ type AlphaOptions struct { // yet working.** [This feature is tracked in // #925](https://github.com/oauth2-proxy/oauth2-proxy/issues/926) Providers Providers `yaml:"providers,omitempty"` + + // Cookie is used to configure the cookies used by OAuth2 Proxy. + // This includes session and CSRF cookies. + Cookie Cookie `yaml:"cookie,omitempty"` } // Initialize alpha options with default values and settings of the core options @@ -63,6 +67,7 @@ func (a *AlphaOptions) ExtractFrom(opts *Options) { a.Server = opts.Server a.MetricsServer = opts.MetricsServer a.Providers = opts.Providers + a.Cookie = opts.Cookie } // MergeOptionsWithDefaults replaces alpha options in the Options struct @@ -74,4 +79,5 @@ func (a *AlphaOptions) MergeOptionsWithDefaults(opts *Options) { opts.Server = a.Server opts.MetricsServer = a.MetricsServer opts.Providers = a.Providers + opts.Cookie = a.Cookie } diff --git a/pkg/apis/options/cookie.go b/pkg/apis/options/cookie.go index 3dee9505..83f1bc85 100644 --- a/pkg/apis/options/cookie.go +++ b/pkg/apis/options/cookie.go @@ -1,70 +1,56 @@ package options import ( - "errors" + "fmt" "os" "time" - "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" - "github.com/spf13/pflag" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" ) -// Cookie contains configuration options relating to Cookie configuration +const ( + // DefaultCookieSecure is the default value for Cookie.Secure + DefaultCookieSecure bool = true + // DefaultCookieHTTPOnly is the default value for Cookie.HTTPOnly + DefaultCookieHTTPOnly bool = true + // DefaultCSRFPerRequest is the default value for Cookie.CSRFPerRequest + DefaultCSRFPerRequest bool = false +) + +// Cookie contains configuration options relating session and CSRF cookies type Cookie struct { - Name string `flag:"cookie-name" cfg:"cookie_name"` - Secret string `flag:"cookie-secret" cfg:"cookie_secret"` - SecretFile string `flag:"cookie-secret-file" cfg:"cookie_secret_file"` - Domains []string `flag:"cookie-domain" cfg:"cookie_domains"` - Path string `flag:"cookie-path" cfg:"cookie_path"` - Expire time.Duration `flag:"cookie-expire" cfg:"cookie_expire"` - Refresh time.Duration `flag:"cookie-refresh" cfg:"cookie_refresh"` - Secure bool `flag:"cookie-secure" cfg:"cookie_secure"` - HTTPOnly bool `flag:"cookie-httponly" cfg:"cookie_httponly"` - SameSite string `flag:"cookie-samesite" cfg:"cookie_samesite"` - CSRFPerRequest bool `flag:"cookie-csrf-per-request" cfg:"cookie_csrf_per_request"` - CSRFPerRequestLimit int `flag:"cookie-csrf-per-request-limit" cfg:"cookie_csrf_per_request_limit"` - CSRFExpire time.Duration `flag:"cookie-csrf-expire" cfg:"cookie_csrf_expire"` - CSRFSameSite string `flag:"cookie-csrf-samesite" cfg:"cookie_csrf_samesite"` -} + // Name is the name of the cookie + Name string `yaml:"name,omitempty"` + // Secret is the secret used to encrypt/sign the cookie value + Secret string `yaml:"secret,omitempty"` + // SecretFile is a file containing the secret used to encrypt/sign the cookie value + // instead of specifying it directly in the config. Secret takes precedence over SecretFile + SecretFile string `yaml:"secretFile,omitempty"` + // Domains is a list of domains for which the cookie is valid + Domains []string `yaml:"domains,omitempty"` + // Path is the path for which the cookie is valid + Path string `yaml:"path,omitempty"` + // Expire is the duration before the cookie expires + Expire time.Duration `yaml:"expire,omitempty"` + // Refresh is the duration after which the cookie is refreshable + Refresh time.Duration `yaml:"refresh,omitempty"` + // Secure indicates whether the cookie is only sent over HTTPS + Secure *bool `yaml:"secure,omitempty"` + // HTTPOnly indicates whether the cookie is inaccessible to JavaScript + HTTPOnly *bool `yaml:"httpOnly,omitempty"` + // SameSite sets the SameSite attribute on the session cookies + SameSite string `yaml:"sameSite,omitempty"` -func cookieFlagSet() *pflag.FlagSet { - flagSet := pflag.NewFlagSet("cookie", pflag.ExitOnError) - - flagSet.String("cookie-name", "_oauth2_proxy", "the name of the cookie that the oauth_proxy creates") - flagSet.String("cookie-secret", "", "the seed string for secure cookies (optionally base64 encoded)") - flagSet.String("cookie-secret-file", "", "For defining a separate cookie secret file to read the encryption key from") - flagSet.StringSlice("cookie-domain", []string{}, "Optional cookie domains to force cookies to (ie: `.yourcompany.com`). The longest domain matching the request's host will be used (or the shortest cookie domain if there is no match).") - flagSet.String("cookie-path", "/", "an optional cookie path to force cookies to (ie: /poc/)*") - flagSet.Duration("cookie-expire", time.Duration(168)*time.Hour, "expire timeframe for cookie") - flagSet.Duration("cookie-refresh", time.Duration(0), "refresh the cookie after this duration; 0 to disable") - flagSet.Bool("cookie-secure", true, "set secure (HTTPS) cookie flag") - flagSet.Bool("cookie-httponly", true, "set HttpOnly cookie flag") - flagSet.String("cookie-samesite", "", "set SameSite cookie attribute (ie: \"lax\", \"strict\", \"none\", or \"\"). ") - flagSet.Bool("cookie-csrf-per-request", false, "When this property is set to true, then the CSRF cookie name is built based on the state and varies per request. If property is set to false, then CSRF cookie has the same name for all requests.") - flagSet.Int("cookie-csrf-per-request-limit", 0, "Sets a limit on the number of CSRF requests cookies that oauth2-proxy will create. The oldest cookies will be removed. Useful if users end up with 431 Request headers too large status codes.") - flagSet.Duration("cookie-csrf-expire", time.Duration(15)*time.Minute, "expire timeframe for CSRF cookie") - flagSet.String("cookie-csrf-samesite", "", "set SameSite CSRF cookie attribute (ie: \"lax\", \"strict\", \"none\", or \"\"). When using the default setting, the CSRF cookie samesite value is taken from the session cookie configuration.") - return flagSet -} - -// cookieDefaults creates a Cookie populating each field with its default value -func cookieDefaults() Cookie { - return Cookie{ - Name: "_oauth2_proxy", - Secret: "", - SecretFile: "", - Domains: nil, - Path: "/", - Expire: time.Duration(168) * time.Hour, - Refresh: time.Duration(0), - Secure: true, - HTTPOnly: true, - SameSite: "", - CSRFPerRequest: false, - CSRFPerRequestLimit: 0, - CSRFExpire: time.Duration(15) * time.Minute, - CSRFSameSite: "", - } + // CSRFSameSite sets the SameSite attribute on the csrf cookies + CSRFSameSite string `yaml:"sameSite,omitempty"` + // CSRFPerRequest indicates whether a unique CSRF token is generated for each request + // Enables parallel requests from clients (e.g., multiple tabs) + CSRFPerRequest *bool `yaml:"csrfPerRequest,omitempty"` + // CSRFPerRequestLimit sets a limit on the number of valid CSRF tokens when CSRFPerRequest is enabled + // Used to prevent unbounded memory growth from storing too many tokens + CSRFPerRequestLimit int `yaml:"csrfPerRequestLimit,omitempty"` + // CSRFExpire sets the duration before a CSRF token expires + CSRFExpire time.Duration `yaml:"csrfExpire,omitempty"` } // GetSecret returns the cookie secret, reading from file if SecretFile is set @@ -75,9 +61,33 @@ func (c *Cookie) GetSecret() (secret string, err error) { fileSecret, err := os.ReadFile(c.SecretFile) if err != nil { - logger.Errorf("error reading cookie secret file %s: %s", c.SecretFile, err) - return "", errors.New("could not read cookie secret file") + return "", fmt.Errorf("error reading cookie secret file %s: %w", c.SecretFile, err) } return string(fileSecret), nil } + +// EnsureDefaults sets any default values for the Cookie configuration +func (c *Cookie) EnsureDefaults() { + if c.Name == "" { + c.Name = "_oauth2_proxy" + } + if c.Path == "" { + c.Path = "/" + } + if c.Expire == 0 { + c.Expire = time.Duration(168) * time.Hour + } + if c.Secure == nil { + c.Secure = ptr.To(DefaultCookieSecure) + } + if c.HTTPOnly == nil { + c.HTTPOnly = ptr.To(DefaultCookieHTTPOnly) + } + if c.CSRFPerRequest == nil { + c.CSRFPerRequest = ptr.To(DefaultCSRFPerRequest) + } + if c.CSRFExpire == 0 { + c.CSRFExpire = time.Duration(15) * time.Minute + } +} diff --git a/pkg/apis/options/cookie_test.go b/pkg/apis/options/cookie_test.go index a1486fed..4fb0e62e 100644 --- a/pkg/apis/options/cookie_test.go +++ b/pkg/apis/options/cookie_test.go @@ -55,7 +55,7 @@ func TestCookieGetSecret(t *testing.T) { secret, err := c.GetSecret() assert.Error(t, err) assert.Equal(t, "", secret) - assert.Contains(t, err.Error(), "could not read cookie secret file") + assert.Contains(t, err.Error(), "error reading cookie secret file /nonexistent/file:") }) t.Run("returns empty when both Secret and SecretFile are empty", func(t *testing.T) { diff --git a/pkg/apis/options/legacy_cookie.go b/pkg/apis/options/legacy_cookie.go new file mode 100644 index 00000000..287db87f --- /dev/null +++ b/pkg/apis/options/legacy_cookie.go @@ -0,0 +1,61 @@ +package options + +import ( + "time" + + "github.com/spf13/pflag" +) + +// LegacyCookie contains configuration options relating to Cookie configuration +type LegacyCookie struct { + Name string `flag:"cookie-name" cfg:"cookie_name"` + Secret string `flag:"cookie-secret" cfg:"cookie_secret"` + SecretFile string `flag:"cookie-secret-file" cfg:"cookie_secret_file"` + Domains []string `flag:"cookie-domain" cfg:"cookie_domains"` + Path string `flag:"cookie-path" cfg:"cookie_path"` + Expire time.Duration `flag:"cookie-expire" cfg:"cookie_expire"` + Refresh time.Duration `flag:"cookie-refresh" cfg:"cookie_refresh"` + Secure bool `flag:"cookie-secure" cfg:"cookie_secure"` + HTTPOnly bool `flag:"cookie-httponly" cfg:"cookie_httponly"` + SameSite string `flag:"cookie-samesite" cfg:"cookie_samesite"` + CSRFPerRequest bool `flag:"cookie-csrf-per-request" cfg:"cookie_csrf_per_request"` + CSRFPerRequestLimit int `flag:"cookie-csrf-per-request-limit" cfg:"cookie_csrf_per_request_limit"` + CSRFExpire time.Duration `flag:"cookie-csrf-expire" cfg:"cookie_csrf_expire"` +} + +func legacyCookieFlagSet() *pflag.FlagSet { + flagSet := pflag.NewFlagSet("cookie", pflag.ExitOnError) + + flagSet.String("cookie-name", "_oauth2_proxy", "the name of the cookie that the oauth_proxy creates") + flagSet.String("cookie-secret", "", "the seed string for secure cookies (optionally base64 encoded)") + flagSet.String("cookie-secret-file", "", "For defining a separate cookie secret file to read the encryption key from") + flagSet.StringSlice("cookie-domain", []string{}, "Optional cookie domains to force cookies to (ie: `.yourcompany.com`). The longest domain matching the request's host will be used (or the shortest cookie domain if there is no match).") + flagSet.String("cookie-path", "/", "an optional cookie path to force cookies to (ie: /poc/)*") + flagSet.Duration("cookie-expire", time.Duration(168)*time.Hour, "expire timeframe for cookie") + flagSet.Duration("cookie-refresh", time.Duration(0), "refresh the cookie after this duration; 0 to disable") + flagSet.Bool("cookie-secure", true, "set secure (HTTPS) cookie flag") + flagSet.Bool("cookie-httponly", true, "set HttpOnly cookie flag") + flagSet.String("cookie-samesite", "", "set SameSite cookie attribute (ie: \"lax\", \"strict\", \"none\", or \"\"). ") + flagSet.Bool("cookie-csrf-per-request", false, "When this property is set to true, then the CSRF cookie name is built based on the state and varies per request. If property is set to false, then CSRF cookie has the same name for all requests.") + flagSet.Int("cookie-csrf-per-request-limit", 0, "Sets a limit on the number of CSRF requests cookies that oauth2-proxy will create. The oldest cookies will be removed. Useful if users end up with 431 Request headers too large status codes.") + flagSet.Duration("cookie-csrf-expire", time.Duration(15)*time.Minute, "expire timeframe for CSRF cookie") + return flagSet +} + +func (l *LegacyCookie) convert() Cookie { + return Cookie{ + Name: l.Name, + Secret: l.Secret, + SecretFile: l.SecretFile, + Domains: l.Domains, + Path: l.Path, + Expire: l.Expire, + Refresh: l.Refresh, + Secure: &l.Secure, + HTTPOnly: &l.HTTPOnly, + SameSite: l.SameSite, + CSRFPerRequest: &l.CSRFPerRequest, + CSRFPerRequestLimit: l.CSRFPerRequestLimit, + CSRFExpire: l.CSRFExpire, + } +} diff --git a/pkg/apis/options/legacy_header.go b/pkg/apis/options/legacy_header.go new file mode 100644 index 00000000..58c337a8 --- /dev/null +++ b/pkg/apis/options/legacy_header.go @@ -0,0 +1,283 @@ +package options + +import ( + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" + "github.com/spf13/pflag" +) + +type LegacyHeaders struct { + PassBasicAuth bool `flag:"pass-basic-auth" cfg:"pass_basic_auth"` + PassAccessToken bool `flag:"pass-access-token" cfg:"pass_access_token"` + PassUserHeaders bool `flag:"pass-user-headers" cfg:"pass_user_headers"` + PassAuthorization bool `flag:"pass-authorization-header" cfg:"pass_authorization_header"` + + SetBasicAuth bool `flag:"set-basic-auth" cfg:"set_basic_auth"` + SetXAuthRequest bool `flag:"set-xauthrequest" cfg:"set_xauthrequest"` + SetAuthorization bool `flag:"set-authorization-header" cfg:"set_authorization_header"` + + PreferEmailToUser bool `flag:"prefer-email-to-user" cfg:"prefer_email_to_user"` + BasicAuthPassword string `flag:"basic-auth-password" cfg:"basic_auth_password"` + SkipAuthStripHeaders bool `flag:"skip-auth-strip-headers" cfg:"skip_auth_strip_headers"` +} + +func legacyHeadersFlagSet() *pflag.FlagSet { + flagSet := pflag.NewFlagSet("headers", pflag.ExitOnError) + + flagSet.Bool("pass-basic-auth", true, "pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream") + flagSet.Bool("pass-access-token", false, "pass OAuth access_token to upstream via X-Forwarded-Access-Token header") + flagSet.Bool("pass-user-headers", true, "pass X-Forwarded-User and X-Forwarded-Email information to upstream") + flagSet.Bool("pass-authorization-header", false, "pass the Authorization Header to upstream") + + flagSet.Bool("set-basic-auth", false, "set HTTP Basic Auth information in response (useful in Nginx auth_request mode)") + flagSet.Bool("set-xauthrequest", false, "set X-Auth-Request-User and X-Auth-Request-Email response headers (useful in Nginx auth_request mode)") + flagSet.Bool("set-authorization-header", false, "set Authorization response headers (useful in Nginx auth_request mode)") + + flagSet.Bool("prefer-email-to-user", false, "Prefer to use the Email address as the Username when passing information to upstream. Will only use Username if Email is unavailable, eg. htaccess authentication. Used in conjunction with -pass-basic-auth and -pass-user-headers") + flagSet.String("basic-auth-password", "", "the password to set when passing the HTTP Basic Auth header") + flagSet.Bool("skip-auth-strip-headers", true, "strips X-Forwarded-* style authentication headers & Authorization header if they would be set by oauth2-proxy") + + return flagSet +} + +// convert takes the legacy request/response headers and converts them to +// the new format for InjectRequestHeaders and InjectResponseHeaders +func (l *LegacyHeaders) convert() ([]Header, []Header) { + return l.getRequestHeaders(), l.getResponseHeaders() +} + +func (l *LegacyHeaders) getRequestHeaders() []Header { + requestHeaders := []Header{} + + if l.PassBasicAuth && l.BasicAuthPassword != "" { + requestHeaders = append(requestHeaders, getBasicAuthHeader(l.PreferEmailToUser, l.BasicAuthPassword)) + } + + // In the old implementation, PassUserHeaders is a subset of PassBasicAuth + if l.PassBasicAuth || l.PassUserHeaders { + requestHeaders = append(requestHeaders, getPassUserHeaders(l.PreferEmailToUser)...) + requestHeaders = append(requestHeaders, getPreferredUsernameHeader()) + } + + if l.PassAccessToken { + requestHeaders = append(requestHeaders, getPassAccessTokenHeader()) + } + + if l.PassAuthorization { + requestHeaders = append(requestHeaders, getAuthorizationHeader()) + } + + for i := range requestHeaders { + requestHeaders[i].PreserveRequestValue = ptr.To(!l.SkipAuthStripHeaders) + } + + return requestHeaders +} + +func (l *LegacyHeaders) getResponseHeaders() []Header { + responseHeaders := []Header{} + + if l.SetXAuthRequest { + responseHeaders = append(responseHeaders, getXAuthRequestHeaders()...) + if l.PassAccessToken { + responseHeaders = append(responseHeaders, getXAuthRequestAccessTokenHeader()) + } + } + + if l.SetBasicAuth { + responseHeaders = append(responseHeaders, getBasicAuthHeader(l.PreferEmailToUser, l.BasicAuthPassword)) + } + + if l.SetAuthorization { + responseHeaders = append(responseHeaders, getAuthorizationHeader()) + } + + return responseHeaders +} + +func getBasicAuthHeader(preferEmailToUser bool, basicAuthPassword string) Header { + claim := "user" + if preferEmailToUser { + claim = "email" + } + + return Header{ + Name: "Authorization", + PreserveRequestValue: ptr.To(false), + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: claim, + Prefix: "Basic ", + BasicAuthPassword: &SecretSource{ + Value: []byte(basicAuthPassword), + }, + }, + }, + }, + } +} + +func getPassUserHeaders(preferEmailToUser bool) []Header { + headers := []Header{ + { + Name: "X-Forwarded-Groups", + PreserveRequestValue: ptr.To(false), + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "groups", + }, + }, + }, + }, + } + + if preferEmailToUser { + return append(headers, + Header{ + Name: "X-Forwarded-User", + PreserveRequestValue: ptr.To(false), + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "email", + }, + }, + }, + }, + ) + } + + return append(headers, + Header{ + Name: "X-Forwarded-User", + PreserveRequestValue: ptr.To(false), + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "user", + }, + }, + }, + }, + Header{ + Name: "X-Forwarded-Email", + PreserveRequestValue: ptr.To(false), + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "email", + }, + }, + }, + }, + ) +} + +func getPassAccessTokenHeader() Header { + return Header{ + Name: "X-Forwarded-Access-Token", + PreserveRequestValue: ptr.To(false), + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "access_token", + }, + }, + }, + } +} + +func getAuthorizationHeader() Header { + return Header{ + Name: "Authorization", + PreserveRequestValue: ptr.To(false), + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "id_token", + Prefix: "Bearer ", + }, + }, + }, + } +} + +func getPreferredUsernameHeader() Header { + return Header{ + Name: "X-Forwarded-Preferred-Username", + PreserveRequestValue: ptr.To(false), + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "preferred_username", + }, + }, + }, + } +} + +func getXAuthRequestHeaders() []Header { + headers := []Header{ + { + Name: "X-Auth-Request-User", + PreserveRequestValue: ptr.To(false), + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "user", + }, + }, + }, + }, + { + Name: "X-Auth-Request-Email", + PreserveRequestValue: ptr.To(false), + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "email", + }, + }, + }, + }, + { + Name: "X-Auth-Request-Preferred-Username", + PreserveRequestValue: ptr.To(false), + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "preferred_username", + }, + }, + }, + }, + { + Name: "X-Auth-Request-Groups", + PreserveRequestValue: ptr.To(false), + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "groups", + }, + }, + }, + }, + } + + return headers +} + +func getXAuthRequestAccessTokenHeader() Header { + return Header{ + Name: "X-Auth-Request-Access-Token", + PreserveRequestValue: ptr.To(false), + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "access_token", + }, + }, + }, + } +} diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index e53fd480..53a3087e 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -2,14 +2,8 @@ package options import ( "fmt" - "net/url" - "reflect" - "strconv" - "strings" "time" - "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" - "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" "github.com/spf13/pflag" ) @@ -26,6 +20,9 @@ type LegacyOptions struct { // Legacy options for single provider LegacyProvider LegacyProvider `cfg:",squash"` + // Legacy options for cookie configuration + LegacyCookie LegacyCookie `cfg:",squash"` + Options Options `cfg:",squash"` } @@ -63,6 +60,21 @@ func NewLegacyOptions() *LegacyOptions { InsecureOIDCSkipNonce: true, }, + LegacyCookie: LegacyCookie{ + Name: "_oauth2_proxy", + Secret: "", + Domains: nil, + Path: "/", + Expire: time.Duration(168) * time.Hour, + Refresh: time.Duration(0), + Secure: true, + HTTPOnly: true, + SameSite: "", + CSRFPerRequest: false, + CSRFPerRequestLimit: 0, + CSRFExpire: time.Duration(15) * time.Minute, + }, + Options: *NewOptions(), } } @@ -75,6 +87,7 @@ func NewLegacyFlagSet() *pflag.FlagSet { flagSet.AddFlagSet(legacyServerFlagset()) flagSet.AddFlagSet(legacyProviderFlagSet()) flagSet.AddFlagSet(legacyGoogleFlagSet()) + flagSet.AddFlagSet(legacyCookieFlagSet()) return flagSet } @@ -89,7 +102,6 @@ func (l *LegacyOptions) ToOptions() (*Options, error) { 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() @@ -97,738 +109,9 @@ func (l *LegacyOptions) ToOptions() (*Options, error) { return nil, fmt.Errorf("error converting provider: %v", err) } l.Options.Providers = providers + l.Options.Cookie = l.LegacyCookie.convert() + l.Options.EnsureDefaults() return &l.Options, nil } - -type LegacyUpstreams struct { - FlushInterval time.Duration `flag:"flush-interval" cfg:"flush_interval"` - PassHostHeader bool `flag:"pass-host-header" cfg:"pass_host_header"` - ProxyWebSockets bool `flag:"proxy-websockets" cfg:"proxy_websockets"` - SSLUpstreamInsecureSkipVerify bool `flag:"ssl-upstream-insecure-skip-verify" cfg:"ssl_upstream_insecure_skip_verify"` - Upstreams []string `flag:"upstream" cfg:"upstreams"` - Timeout time.Duration `flag:"upstream-timeout" cfg:"upstream_timeout"` - DisableKeepAlives bool `flag:"disable-keep-alives" cfg:"disable_keep_alives"` -} - -func legacyUpstreamsFlagSet() *pflag.FlagSet { - flagSet := pflag.NewFlagSet("upstreams", pflag.ExitOnError) - - flagSet.Duration("flush-interval", DefaultUpstreamFlushInterval, "period between response flushing when streaming responses") - flagSet.Bool("pass-host-header", true, "pass the request Host Header to upstream") - flagSet.Bool("proxy-websockets", true, "enables WebSocket proxying") - flagSet.Bool("ssl-upstream-insecure-skip-verify", false, "skip validation of certificates presented when using HTTPS upstreams") - flagSet.StringSlice("upstream", []string{}, "the http url(s) of the upstream endpoint, file:// paths for static files or static:// for static response. Routing is based on the path") - flagSet.Duration("upstream-timeout", DefaultUpstreamTimeout, "maximum amount of time the server will wait for a response from the upstream") - flagSet.Bool("disable-keep-alives", false, "disable HTTP keep-alive connections to the upstream server") - - return flagSet -} - -func (l *LegacyUpstreams) convert() (UpstreamConfig, error) { - upstreams := UpstreamConfig{} - - for _, upstreamString := range l.Upstreams { - u, err := url.Parse(upstreamString) - if err != nil { - return UpstreamConfig{}, fmt.Errorf("could not parse upstream %q: %v", upstreamString, err) - } - - if u.Path == "" { - u.Path = "/" - } - - flushInterval := l.FlushInterval - timeout := l.Timeout - upstream := Upstream{ - ID: u.Path, - Path: u.Path, - URI: upstreamString, - InsecureSkipTLSVerify: &l.SSLUpstreamInsecureSkipVerify, - PassHostHeader: &l.PassHostHeader, - ProxyWebSockets: &l.ProxyWebSockets, - FlushInterval: &flushInterval, - Timeout: &timeout, - DisableKeepAlives: &l.DisableKeepAlives, - } - - switch u.Scheme { - case "file": - if u.Fragment != "" { - upstream.ID = u.Fragment - upstream.Path = u.Fragment - // Trim the fragment from the end of the URI - upstream.URI = strings.SplitN(upstreamString, "#", 2)[0] - } - case "static": - responseCode, err := strconv.Atoi(u.Host) - if err != nil { - logger.Errorf("unable to convert %q to int, use default \"200\"", u.Host) - responseCode = 200 - } - upstream.Static = ptr.To(true) - upstream.StaticCode = &responseCode - - // This is not allowed to be empty and must be unique - upstream.ID = upstreamString - - // We only support the root path in the legacy config - upstream.Path = "/" - - // Force defaults compatible with static responses - upstream.URI = "" - upstream.InsecureSkipTLSVerify = ptr.To(false) - upstream.DisableKeepAlives = ptr.To(false) - upstream.PassHostHeader = ptr.To(false) - upstream.ProxyWebSockets = ptr.To(false) - upstream.FlushInterval = ptr.To(DefaultUpstreamFlushInterval) - upstream.Timeout = ptr.To(DefaultUpstreamTimeout) - case "unix": - upstream.Path = "/" - } - - upstreams.Upstreams = append(upstreams.Upstreams, upstream) - } - - return upstreams, nil -} - -type LegacyHeaders struct { - PassBasicAuth bool `flag:"pass-basic-auth" cfg:"pass_basic_auth"` - PassAccessToken bool `flag:"pass-access-token" cfg:"pass_access_token"` - PassUserHeaders bool `flag:"pass-user-headers" cfg:"pass_user_headers"` - PassAuthorization bool `flag:"pass-authorization-header" cfg:"pass_authorization_header"` - - SetBasicAuth bool `flag:"set-basic-auth" cfg:"set_basic_auth"` - SetXAuthRequest bool `flag:"set-xauthrequest" cfg:"set_xauthrequest"` - SetAuthorization bool `flag:"set-authorization-header" cfg:"set_authorization_header"` - - PreferEmailToUser bool `flag:"prefer-email-to-user" cfg:"prefer_email_to_user"` - BasicAuthPassword string `flag:"basic-auth-password" cfg:"basic_auth_password"` - SkipAuthStripHeaders bool `flag:"skip-auth-strip-headers" cfg:"skip_auth_strip_headers"` -} - -func legacyHeadersFlagSet() *pflag.FlagSet { - flagSet := pflag.NewFlagSet("headers", pflag.ExitOnError) - - flagSet.Bool("pass-basic-auth", true, "pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream") - flagSet.Bool("pass-access-token", false, "pass OAuth access_token to upstream via X-Forwarded-Access-Token header") - flagSet.Bool("pass-user-headers", true, "pass X-Forwarded-User and X-Forwarded-Email information to upstream") - flagSet.Bool("pass-authorization-header", false, "pass the Authorization Header to upstream") - - flagSet.Bool("set-basic-auth", false, "set HTTP Basic Auth information in response (useful in Nginx auth_request mode)") - flagSet.Bool("set-xauthrequest", false, "set X-Auth-Request-User and X-Auth-Request-Email response headers (useful in Nginx auth_request mode)") - flagSet.Bool("set-authorization-header", false, "set Authorization response headers (useful in Nginx auth_request mode)") - - flagSet.Bool("prefer-email-to-user", false, "Prefer to use the Email address as the Username when passing information to upstream. Will only use Username if Email is unavailable, eg. htaccess authentication. Used in conjunction with -pass-basic-auth and -pass-user-headers") - flagSet.String("basic-auth-password", "", "the password to set when passing the HTTP Basic Auth header") - flagSet.Bool("skip-auth-strip-headers", true, "strips X-Forwarded-* style authentication headers & Authorization header if they would be set by oauth2-proxy") - - return flagSet -} - -// convert takes the legacy request/response headers and converts them to -// the new format for InjectRequestHeaders and InjectResponseHeaders -func (l *LegacyHeaders) convert() ([]Header, []Header) { - return l.getRequestHeaders(), l.getResponseHeaders() -} - -func (l *LegacyHeaders) getRequestHeaders() []Header { - requestHeaders := []Header{} - - if l.PassBasicAuth && l.BasicAuthPassword != "" { - requestHeaders = append(requestHeaders, getBasicAuthHeader(l.PreferEmailToUser, l.BasicAuthPassword)) - } - - // In the old implementation, PassUserHeaders is a subset of PassBasicAuth - if l.PassBasicAuth || l.PassUserHeaders { - requestHeaders = append(requestHeaders, getPassUserHeaders(l.PreferEmailToUser)...) - requestHeaders = append(requestHeaders, getPreferredUsernameHeader()) - } - - if l.PassAccessToken { - requestHeaders = append(requestHeaders, getPassAccessTokenHeader()) - } - - if l.PassAuthorization { - requestHeaders = append(requestHeaders, getAuthorizationHeader()) - } - - for i := range requestHeaders { - requestHeaders[i].PreserveRequestValue = ptr.To(!l.SkipAuthStripHeaders) - } - - return requestHeaders -} - -func (l *LegacyHeaders) getResponseHeaders() []Header { - responseHeaders := []Header{} - - if l.SetXAuthRequest { - responseHeaders = append(responseHeaders, getXAuthRequestHeaders()...) - if l.PassAccessToken { - responseHeaders = append(responseHeaders, getXAuthRequestAccessTokenHeader()) - } - } - - if l.SetBasicAuth { - responseHeaders = append(responseHeaders, getBasicAuthHeader(l.PreferEmailToUser, l.BasicAuthPassword)) - } - - if l.SetAuthorization { - responseHeaders = append(responseHeaders, getAuthorizationHeader()) - } - - return responseHeaders -} - -func getBasicAuthHeader(preferEmailToUser bool, basicAuthPassword string) Header { - claim := "user" - if preferEmailToUser { - claim = "email" - } - - return Header{ - Name: "Authorization", - PreserveRequestValue: ptr.To(false), - Values: []HeaderValue{ - { - ClaimSource: &ClaimSource{ - Claim: claim, - Prefix: "Basic ", - BasicAuthPassword: &SecretSource{ - Value: []byte(basicAuthPassword), - }, - }, - }, - }, - } -} - -func getPassUserHeaders(preferEmailToUser bool) []Header { - headers := []Header{ - { - Name: "X-Forwarded-Groups", - PreserveRequestValue: ptr.To(false), - Values: []HeaderValue{ - { - ClaimSource: &ClaimSource{ - Claim: "groups", - }, - }, - }, - }, - } - - if preferEmailToUser { - return append(headers, - Header{ - Name: "X-Forwarded-User", - PreserveRequestValue: ptr.To(false), - Values: []HeaderValue{ - { - ClaimSource: &ClaimSource{ - Claim: "email", - }, - }, - }, - }, - ) - } - - return append(headers, - Header{ - Name: "X-Forwarded-User", - PreserveRequestValue: ptr.To(false), - Values: []HeaderValue{ - { - ClaimSource: &ClaimSource{ - Claim: "user", - }, - }, - }, - }, - Header{ - Name: "X-Forwarded-Email", - PreserveRequestValue: ptr.To(false), - Values: []HeaderValue{ - { - ClaimSource: &ClaimSource{ - Claim: "email", - }, - }, - }, - }, - ) -} - -func getPassAccessTokenHeader() Header { - return Header{ - Name: "X-Forwarded-Access-Token", - PreserveRequestValue: ptr.To(false), - Values: []HeaderValue{ - { - ClaimSource: &ClaimSource{ - Claim: "access_token", - }, - }, - }, - } -} - -func getAuthorizationHeader() Header { - return Header{ - Name: "Authorization", - PreserveRequestValue: ptr.To(false), - Values: []HeaderValue{ - { - ClaimSource: &ClaimSource{ - Claim: "id_token", - Prefix: "Bearer ", - }, - }, - }, - } -} - -func getPreferredUsernameHeader() Header { - return Header{ - Name: "X-Forwarded-Preferred-Username", - PreserveRequestValue: ptr.To(false), - Values: []HeaderValue{ - { - ClaimSource: &ClaimSource{ - Claim: "preferred_username", - }, - }, - }, - } -} - -func getXAuthRequestHeaders() []Header { - headers := []Header{ - { - Name: "X-Auth-Request-User", - PreserveRequestValue: ptr.To(false), - Values: []HeaderValue{ - { - ClaimSource: &ClaimSource{ - Claim: "user", - }, - }, - }, - }, - { - Name: "X-Auth-Request-Email", - PreserveRequestValue: ptr.To(false), - Values: []HeaderValue{ - { - ClaimSource: &ClaimSource{ - Claim: "email", - }, - }, - }, - }, - { - Name: "X-Auth-Request-Preferred-Username", - PreserveRequestValue: ptr.To(false), - Values: []HeaderValue{ - { - ClaimSource: &ClaimSource{ - Claim: "preferred_username", - }, - }, - }, - }, - { - Name: "X-Auth-Request-Groups", - PreserveRequestValue: ptr.To(false), - Values: []HeaderValue{ - { - ClaimSource: &ClaimSource{ - Claim: "groups", - }, - }, - }, - }, - } - - return headers -} - -func getXAuthRequestAccessTokenHeader() Header { - return Header{ - Name: "X-Auth-Request-Access-Token", - PreserveRequestValue: ptr.To(false), - Values: []HeaderValue{ - { - ClaimSource: &ClaimSource{ - Claim: "access_token", - }, - }, - }, - } -} - -type LegacyServer struct { - MetricsAddress string `flag:"metrics-address" cfg:"metrics_address"` - MetricsSecureAddress string `flag:"metrics-secure-address" cfg:"metrics_secure_address"` - MetricsTLSCertFile string `flag:"metrics-tls-cert-file" cfg:"metrics_tls_cert_file"` - MetricsTLSKeyFile string `flag:"metrics-tls-key-file" cfg:"metrics_tls_key_file"` - HTTPAddress string `flag:"http-address" cfg:"http_address"` - HTTPSAddress string `flag:"https-address" cfg:"https_address"` - TLSCertFile string `flag:"tls-cert-file" cfg:"tls_cert_file"` - TLSKeyFile string `flag:"tls-key-file" cfg:"tls_key_file"` - TLSMinVersion string `flag:"tls-min-version" cfg:"tls_min_version"` - TLSCipherSuites []string `flag:"tls-cipher-suite" cfg:"tls_cipher_suites"` -} - -func legacyServerFlagset() *pflag.FlagSet { - flagSet := pflag.NewFlagSet("server", pflag.ExitOnError) - - flagSet.String("metrics-address", "", "the address /metrics will be served on (e.g. \":9100\")") - flagSet.String("metrics-secure-address", "", "the address /metrics will be served on for HTTPS clients (e.g. \":9100\")") - flagSet.String("metrics-tls-cert-file", "", "path to certificate file for secure metrics server") - flagSet.String("metrics-tls-key-file", "", "path to private key file for secure metrics server") - flagSet.String("http-address", "127.0.0.1:4180", "[http://]: or unix:// or fd: (case insensitive) to listen on for HTTP clients") - flagSet.String("https-address", ":443", ": to listen on for HTTPS clients") - flagSet.String("tls-cert-file", "", "path to certificate file") - flagSet.String("tls-key-file", "", "path to private key file") - flagSet.String("tls-min-version", "", "minimal TLS version for HTTPS clients (either \"TLS1.2\" or \"TLS1.3\")") - flagSet.StringSlice("tls-cipher-suite", []string{}, "restricts TLS cipher suites to those listed (e.g. TLS_RSA_WITH_RC4_128_SHA) (may be given multiple times)") - - 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"` - AzureGraphGroupField string `flag:"azure-graph-group-field" cfg:"azure_graph_group_field"` - EntraIDAllowedTenants []string `flag:"entra-id-allowed-tenant" cfg:"entra_id_allowed_tenants"` - EntraIDFederatedTokenAuth bool `flag:"entra-id-federated-token-auth" cfg:"entra_id_federated_token_auth"` - 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"` - GoogleGroupsLegacy []string `flag:"google-group" cfg:"google_group"` - GoogleGroups []string `flag:"google-group" cfg:"google_groups"` - 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"` - GoogleTargetPrincipal string `flag:"google-target-principal" cfg:"google_target_principal"` - GoogleUseOrganizationID bool `flag:"google-use-organization-id" cfg:"google_use_organization_id"` - GoogleAdminAPIUserScope string `flag:"google-admin-api-user-scope" cfg:"google_admin_api_user_scope"` - - // 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"` - UseSystemTrustStore bool `flag:"use-system-trust-store" cfg:"use_system_trust_store"` - 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"` - InsecureOIDCSkipNonce bool `flag:"insecure-oidc-skip-nonce" cfg:"insecure_oidc_skip_nonce"` - 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"` - OIDCAudienceClaims []string `flag:"oidc-audience-claim" cfg:"oidc_audience_claims"` - OIDCExtraAudiences []string `flag:"oidc-extra-audience" cfg:"oidc_extra_audiences"` - OIDCPublicKeyFiles []string `flag:"oidc-public-key-file" cfg:"oidc_public_key_files"` - OIDCEnabledSigningAlgs []string `flag:"oidc-enabled-signing-alg" cfg:"oidc_enabled_signing_algs"` - LoginURL string `flag:"login-url" cfg:"login_url"` - AuthRequestResponseMode string `flag:"auth-request-response-mode" cfg:"auth_request_response_mode"` - RedeemURL string `flag:"redeem-url" cfg:"redeem_url"` - ProfileURL string `flag:"profile-url" cfg:"profile_url"` - SkipClaimsFromProfileURL bool `flag:"skip-claims-from-profile-url" cfg:"skip_claims_from_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"` - AllowedRoles []string `flag:"allowed-role" cfg:"allowed_roles"` - BackendLogoutURL string `flag:"backend-logout-url" cfg:"backend_logout_url"` - - 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"` - // PKCE Code Challenge method to use (either S256 or plain) - CodeChallengeMethod string `flag:"code-challenge-method" cfg:"code_challenge_method"` - // Provided for legacy reasons, to be dropped in newer version see #1667 - ForceCodeChallengeMethod string `flag:"force-code-challenge-method" cfg:"force_code_challenge_method"` -} - -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("azure-graph-group-field", "", "configures the group field to be used when building the groups list(`id` or `displayName`. Default is `id`) from Microsoft Graph(available only for v2.0 oidc url). Based on this value, the `allowed-group` config values should be adjusted accordingly. If using `id` as group field, `allowed-group` should contains groups IDs, if using `displayName` as group field, `allowed-group` should contains groups name") - flagSet.StringSlice("entra-id-allowed-tenant", []string{}, "list of tenants allowed for MS Entra ID multi-tenant application") - flagSet.Bool("entra-id-federated-token-auth", false, "enable oAuth client authentication with federated token projected by Azure Workload Identity plugin, instead of client secret.") - 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.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.Bool("use-system-trust-store", false, "Determines if 'provider-ca-file' files and the system trust store are used. If set to true, your custom CA files and the system trust store are used otherwise only your custom CA files.") - 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("insecure-oidc-skip-nonce", true, "skip verifying the OIDC ID Token's nonce claim") - 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", OIDCGroupsClaim, "which OIDC claim contains the user groups") - flagSet.String("oidc-email-claim", OIDCEmailClaim, "which OIDC claim contains the user's email") - flagSet.StringSlice("oidc-audience-claim", OIDCAudienceClaims, "which OIDC claims are used as audience to verify against client id") - flagSet.StringSlice("oidc-extra-audience", []string{}, "additional audiences allowed to pass audience verification") - flagSet.StringSlice("oidc-public-key-file", []string{}, "path to public key file in PEM format to use for verifying JWT tokens (may be given multiple times)") - flagSet.StringSlice("oidc-enabled-signing-alg", []string{}, "accepted signing algorithms for provider to use") - flagSet.String("login-url", "", "Authentication endpoint") - flagSet.String("redeem-url", "", "Token redemption endpoint") - flagSet.String("profile-url", "", "Profile access endpoint") - flagSet.String("auth-request-response-mode", "", "Authorization request response mode") - flagSet.Bool("skip-claims-from-profile-url", false, "Skip loading missing claims from profile URL") - 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("code-challenge-method", "", "use PKCE code challenges with the specified method. Either 'plain' or 'S256'") - flagSet.String("force-code-challenge-method", "", "Deprecated - use --code-challenge-method") - - 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", 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.StringSlice("allowed-role", []string{}, "(keycloak-oidc) restrict logins to members of these roles (may be given multiple times)") - flagSet.String("backend-logout-url", "", "url to perform a backend logout, {id_token} can be used as placeholder for the id_token") - - 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)") - flagSet.String("google-target-principal", "", "the target principal to impersonate when using ADC") - flagSet.String("google-use-organization-id", "", "use organization id as preferred username") - flagSet.String("google-admin-api-user-scope", "", "authorization scope required to call users.get, can be one of ") - - return flagSet -} - -func (l LegacyServer) convert() (Server, Server) { - appServer := Server{ - BindAddress: l.HTTPAddress, - SecureBindAddress: l.HTTPSAddress, - } - if l.TLSKeyFile != "" || l.TLSCertFile != "" { - appServer.TLS = &TLS{ - Key: &SecretSource{ - FromFile: l.TLSKeyFile, - }, - Cert: &SecretSource{ - FromFile: l.TLSCertFile, - }, - MinVersion: l.TLSMinVersion, - } - if len(l.TLSCipherSuites) != 0 { - appServer.TLS.CipherSuites = l.TLSCipherSuites - } - // Preserve backwards compatibility, only run one server - appServer.BindAddress = "" - } else { - // Disable the HTTPS server if there's no certificates. - // This preserves backwards compatibility. - appServer.SecureBindAddress = "" - } - - metricsServer := Server{ - BindAddress: l.MetricsAddress, - SecureBindAddress: l.MetricsSecureAddress, - } - if l.MetricsTLSKeyFile != "" || l.MetricsTLSCertFile != "" { - metricsServer.TLS = &TLS{ - Key: &SecretSource{ - FromFile: l.MetricsTLSKeyFile, - }, - Cert: &SecretSource{ - FromFile: l.MetricsTLSCertFile, - }, - } - } - - return appServer, metricsServer -} - -func (l *LegacyProvider) convert() (Providers, error) { - providers := make(Providers, 0, 1) - - provider := Provider{ - ClientID: l.ClientID, - ClientSecret: l.ClientSecret, - ClientSecretFile: l.ClientSecretFile, - Type: ProviderType(l.ProviderType), - CAFiles: l.ProviderCAFiles, - UseSystemTrustStore: &l.UseSystemTrustStore, - LoginURL: l.LoginURL, - RedeemURL: l.RedeemURL, - ProfileURL: l.ProfileURL, - SkipClaimsFromProfileURL: &l.SkipClaimsFromProfileURL, - ProtectedResource: l.ProtectedResource, - ValidateURL: l.ValidateURL, - Scope: l.Scope, - AllowedGroups: l.AllowedGroups, - CodeChallengeMethod: l.CodeChallengeMethod, - BackendLogoutURL: l.BackendLogoutURL, - AuthRequestResponseMode: l.AuthRequestResponseMode, - } - - // 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, - InsecureSkipNonce: &l.InsecureOIDCSkipNonce, - SkipDiscovery: &l.SkipOIDCDiscovery, - JwksURL: l.OIDCJwksURL, - UserIDClaim: l.UserIDClaim, - EmailClaim: l.OIDCEmailClaim, - GroupsClaim: l.OIDCGroupsClaim, - AudienceClaims: l.OIDCAudienceClaims, - ExtraAudiences: l.OIDCExtraAudiences, - PublicKeyFiles: l.OIDCPublicKeyFiles, - EnabledSigningAlgs: l.OIDCEnabledSigningAlgs, - } - - // Support for legacy configuration option - if l.ForceCodeChallengeMethod != "" && l.CodeChallengeMethod == "" { - provider.CodeChallengeMethod = l.ForceCodeChallengeMethod - } - - // 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, - GraphGroupField: l.AzureGraphGroupField, - } - - 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-oidc": - provider.KeycloakConfig = KeycloakOptions{ - Groups: l.KeycloakGroups, - Roles: l.AllowedRoles, - } - 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": - if len(l.GoogleGroupsLegacy) != 0 && !reflect.DeepEqual(l.GoogleGroupsLegacy, l.GoogleGroups) { - // Log the deprecation notice - logger.Error( - "WARNING: The 'OAUTH2_PROXY_GOOGLE_GROUP' environment variable is deprecated and will likely be removed in the next major release. Use 'OAUTH2_PROXY_GOOGLE_GROUPS' instead.", - ) - l.GoogleGroups = l.GoogleGroupsLegacy - } - provider.GoogleConfig = GoogleOptions{ - Groups: l.GoogleGroups, - AdminEmail: l.GoogleAdminEmail, - ServiceAccountJSON: l.GoogleServiceAccountJSON, - UseApplicationDefaultCredentials: &l.GoogleUseApplicationDefaultCredentials, - TargetPrincipal: l.GoogleTargetPrincipal, - UseOrganizationID: &l.GoogleUseOrganizationID, - AdminAPIUserScope: l.GoogleAdminAPIUserScope, - } - case "entra-id": - provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{ - AllowedTenants: l.EntraIDAllowedTenants, - FederatedTokenAuth: &l.EntraIDFederatedTokenAuth, - } - } - - if l.ProviderName != "" { - provider.ID = l.ProviderName - provider.Name = l.ProviderName - } else { - provider.ID = l.ProviderType + "=" + l.ClientID - } - - // handle AcrValues, Prompt and ApprovalPrompt - var urlParams []LoginURLParameter - if l.AcrValues != "" { - urlParams = append(urlParams, LoginURLParameter{Name: "acr_values", Default: []string{l.AcrValues}}) - } - switch { - case l.Prompt != "": - urlParams = append(urlParams, LoginURLParameter{Name: "prompt", Default: []string{l.Prompt}}) - case l.ApprovalPrompt != "": - urlParams = append(urlParams, LoginURLParameter{Name: "approval_prompt", Default: []string{l.ApprovalPrompt}}) - default: - // match legacy behaviour by default - if neither prompt nor approval_prompt - // specified, use approval_prompt=force - urlParams = append(urlParams, LoginURLParameter{Name: "approval_prompt", Default: []string{"force"}}) - } - - provider.LoginURLParameters = urlParams - - 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 f6cbfb7c..438e8f53 100644 --- a/pkg/apis/options/legacy_options_test.go +++ b/pkg/apis/options/legacy_options_test.go @@ -1086,4 +1086,54 @@ var _ = Describe("Legacy Options", func() { }), ) }) + + Context("Legacy Cookie", func() { + type convertCookieTableInput struct { + legacyCookie LegacyCookie + expectedCookie Cookie + } + + // Test cases and expected outcomes + fullCookie := Cookie{ + Name: "_oauth2_proxy", + Secret: "", + Domains: nil, + Path: "/", + Expire: time.Duration(168) * time.Hour, + Refresh: time.Duration(0), + Secure: ptr.To(true), + HTTPOnly: ptr.To(true), + SameSite: "", + CSRFPerRequest: ptr.To(false), + CSRFPerRequestLimit: 0, + CSRFExpire: time.Duration(15) * time.Minute, + } + + fullLegacyCookie := LegacyCookie{ + Name: "_oauth2_proxy", + Secret: "", + Domains: nil, + Path: "/", + Expire: time.Duration(168) * time.Hour, + Refresh: time.Duration(0), + Secure: true, + HTTPOnly: true, + SameSite: "", + CSRFPerRequest: false, + CSRFPerRequestLimit: 0, + CSRFExpire: time.Duration(15) * time.Minute, + } + + DescribeTable("convertLegacyCookie", + func(in *convertCookieTableInput) { + cookie := in.legacyCookie.convert() + + Expect(cookie).To(BeEquivalentTo(in.expectedCookie)) + }, + Entry("with all attributes", &convertCookieTableInput{ + legacyCookie: fullLegacyCookie, + expectedCookie: fullCookie, + }), + ) + }) }) diff --git a/pkg/apis/options/legacy_provider.go b/pkg/apis/options/legacy_provider.go new file mode 100644 index 00000000..e736eb63 --- /dev/null +++ b/pkg/apis/options/legacy_provider.go @@ -0,0 +1,320 @@ +package options + +import ( + "reflect" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" + "github.com/spf13/pflag" +) + +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"` + AzureGraphGroupField string `flag:"azure-graph-group-field" cfg:"azure_graph_group_field"` + EntraIDAllowedTenants []string `flag:"entra-id-allowed-tenant" cfg:"entra_id_allowed_tenants"` + EntraIDFederatedTokenAuth bool `flag:"entra-id-federated-token-auth" cfg:"entra_id_federated_token_auth"` + 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"` + GoogleGroupsLegacy []string `flag:"google-group" cfg:"google_group"` + GoogleGroups []string `flag:"google-group" cfg:"google_groups"` + 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"` + GoogleTargetPrincipal string `flag:"google-target-principal" cfg:"google_target_principal"` + GoogleUseOrganizationID bool `flag:"google-use-organization-id" cfg:"google_use_organization_id"` + GoogleAdminAPIUserScope string `flag:"google-admin-api-user-scope" cfg:"google_admin_api_user_scope"` + + // 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"` + UseSystemTrustStore bool `flag:"use-system-trust-store" cfg:"use_system_trust_store"` + 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"` + InsecureOIDCSkipNonce bool `flag:"insecure-oidc-skip-nonce" cfg:"insecure_oidc_skip_nonce"` + 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"` + OIDCAudienceClaims []string `flag:"oidc-audience-claim" cfg:"oidc_audience_claims"` + OIDCExtraAudiences []string `flag:"oidc-extra-audience" cfg:"oidc_extra_audiences"` + OIDCPublicKeyFiles []string `flag:"oidc-public-key-file" cfg:"oidc_public_key_files"` + OIDCEnabledSigningAlgs []string `flag:"oidc-enabled-signing-alg" cfg:"oidc_enabled_signing_algs"` + LoginURL string `flag:"login-url" cfg:"login_url"` + AuthRequestResponseMode string `flag:"auth-request-response-mode" cfg:"auth_request_response_mode"` + RedeemURL string `flag:"redeem-url" cfg:"redeem_url"` + ProfileURL string `flag:"profile-url" cfg:"profile_url"` + SkipClaimsFromProfileURL bool `flag:"skip-claims-from-profile-url" cfg:"skip_claims_from_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"` + AllowedRoles []string `flag:"allowed-role" cfg:"allowed_roles"` + BackendLogoutURL string `flag:"backend-logout-url" cfg:"backend_logout_url"` + + 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"` + // PKCE Code Challenge method to use (either S256 or plain) + CodeChallengeMethod string `flag:"code-challenge-method" cfg:"code_challenge_method"` + // Provided for legacy reasons, to be dropped in newer version see #1667 + ForceCodeChallengeMethod string `flag:"force-code-challenge-method" cfg:"force_code_challenge_method"` +} + +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("azure-graph-group-field", "", "configures the group field to be used when building the groups list(`id` or `displayName`. Default is `id`) from Microsoft Graph(available only for v2.0 oidc url). Based on this value, the `allowed-group` config values should be adjusted accordingly. If using `id` as group field, `allowed-group` should contains groups IDs, if using `displayName` as group field, `allowed-group` should contains groups name") + flagSet.StringSlice("entra-id-allowed-tenant", []string{}, "list of tenants allowed for MS Entra ID multi-tenant application") + flagSet.Bool("entra-id-federated-token-auth", false, "enable oAuth client authentication with federated token projected by Azure Workload Identity plugin, instead of client secret.") + 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.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.Bool("use-system-trust-store", false, "Determines if 'provider-ca-file' files and the system trust store are used. If set to true, your custom CA files and the system trust store are used otherwise only your custom CA files.") + 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("insecure-oidc-skip-nonce", true, "skip verifying the OIDC ID Token's nonce claim") + 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", OIDCGroupsClaim, "which OIDC claim contains the user groups") + flagSet.String("oidc-email-claim", OIDCEmailClaim, "which OIDC claim contains the user's email") + flagSet.StringSlice("oidc-audience-claim", OIDCAudienceClaims, "which OIDC claims are used as audience to verify against client id") + flagSet.StringSlice("oidc-extra-audience", []string{}, "additional audiences allowed to pass audience verification") + flagSet.StringSlice("oidc-public-key-file", []string{}, "path to public key file in PEM format to use for verifying JWT tokens (may be given multiple times)") + flagSet.StringSlice("oidc-enabled-signing-alg", []string{}, "accepted signing algorithms for provider to use") + flagSet.String("login-url", "", "Authentication endpoint") + flagSet.String("redeem-url", "", "Token redemption endpoint") + flagSet.String("profile-url", "", "Profile access endpoint") + flagSet.String("auth-request-response-mode", "", "Authorization request response mode") + flagSet.Bool("skip-claims-from-profile-url", false, "Skip loading missing claims from profile URL") + 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("code-challenge-method", "", "use PKCE code challenges with the specified method. Either 'plain' or 'S256'") + flagSet.String("force-code-challenge-method", "", "Deprecated - use --code-challenge-method") + + 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", 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.StringSlice("allowed-role", []string{}, "(keycloak-oidc) restrict logins to members of these roles (may be given multiple times)") + flagSet.String("backend-logout-url", "", "url to perform a backend logout, {id_token} can be used as placeholder for the id_token") + + 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)") + flagSet.String("google-target-principal", "", "the target principal to impersonate when using ADC") + flagSet.String("google-use-organization-id", "", "use organization id as preferred username") + flagSet.String("google-admin-api-user-scope", "", "authorization scope required to call users.get, can be one of ") + + return flagSet +} + +func (l *LegacyProvider) convert() (Providers, error) { + providers := Providers{} + + provider := Provider{ + ClientID: l.ClientID, + ClientSecret: l.ClientSecret, + ClientSecretFile: l.ClientSecretFile, + Type: ProviderType(l.ProviderType), + CAFiles: l.ProviderCAFiles, + UseSystemTrustStore: &l.UseSystemTrustStore, + LoginURL: l.LoginURL, + RedeemURL: l.RedeemURL, + ProfileURL: l.ProfileURL, + SkipClaimsFromProfileURL: &l.SkipClaimsFromProfileURL, + ProtectedResource: l.ProtectedResource, + ValidateURL: l.ValidateURL, + Scope: l.Scope, + AllowedGroups: l.AllowedGroups, + CodeChallengeMethod: l.CodeChallengeMethod, + BackendLogoutURL: l.BackendLogoutURL, + AuthRequestResponseMode: l.AuthRequestResponseMode, + } + + // 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, + InsecureSkipNonce: &l.InsecureOIDCSkipNonce, + SkipDiscovery: &l.SkipOIDCDiscovery, + JwksURL: l.OIDCJwksURL, + UserIDClaim: l.UserIDClaim, + EmailClaim: l.OIDCEmailClaim, + GroupsClaim: l.OIDCGroupsClaim, + AudienceClaims: l.OIDCAudienceClaims, + ExtraAudiences: l.OIDCExtraAudiences, + PublicKeyFiles: l.OIDCPublicKeyFiles, + EnabledSigningAlgs: l.OIDCEnabledSigningAlgs, + } + + // Support for legacy configuration option + if l.ForceCodeChallengeMethod != "" && l.CodeChallengeMethod == "" { + provider.CodeChallengeMethod = l.ForceCodeChallengeMethod + } + + // 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, + GraphGroupField: l.AzureGraphGroupField, + } + + 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-oidc": + provider.KeycloakConfig = KeycloakOptions{ + Groups: l.KeycloakGroups, + Roles: l.AllowedRoles, + } + 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": + if len(l.GoogleGroupsLegacy) != 0 && !reflect.DeepEqual(l.GoogleGroupsLegacy, l.GoogleGroups) { + // Log the deprecation notice + logger.Error( + "WARNING: The 'OAUTH2_PROXY_GOOGLE_GROUP' environment variable is deprecated and will likely be removed in the next major release. Use 'OAUTH2_PROXY_GOOGLE_GROUPS' instead.", + ) + l.GoogleGroups = l.GoogleGroupsLegacy + } + provider.GoogleConfig = GoogleOptions{ + Groups: l.GoogleGroups, + AdminEmail: l.GoogleAdminEmail, + ServiceAccountJSON: l.GoogleServiceAccountJSON, + UseApplicationDefaultCredentials: &l.GoogleUseApplicationDefaultCredentials, + TargetPrincipal: l.GoogleTargetPrincipal, + UseOrganizationID: &l.GoogleUseOrganizationID, + AdminAPIUserScope: l.GoogleAdminAPIUserScope, + } + case "entra-id": + provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{ + AllowedTenants: l.EntraIDAllowedTenants, + FederatedTokenAuth: &l.EntraIDFederatedTokenAuth, + } + } + + if l.ProviderName != "" { + provider.ID = l.ProviderName + provider.Name = l.ProviderName + } else { + provider.ID = l.ProviderType + "=" + l.ClientID + } + + // handle AcrValues, Prompt and ApprovalPrompt + var urlParams []LoginURLParameter + if l.AcrValues != "" { + urlParams = append(urlParams, LoginURLParameter{Name: "acr_values", Default: []string{l.AcrValues}}) + } + switch { + case l.Prompt != "": + urlParams = append(urlParams, LoginURLParameter{Name: "prompt", Default: []string{l.Prompt}}) + case l.ApprovalPrompt != "": + urlParams = append(urlParams, LoginURLParameter{Name: "approval_prompt", Default: []string{l.ApprovalPrompt}}) + default: + // match legacy behaviour by default - if neither prompt nor approval_prompt + // specified, use approval_prompt=force + urlParams = append(urlParams, LoginURLParameter{Name: "approval_prompt", Default: []string{"force"}}) + } + + provider.LoginURLParameters = urlParams + + providers = append(providers, provider) + + return providers, nil +} + +// Legacy default providers configuration +func providerDefaults() Providers { + providers := Providers{ + { + Type: "google", + AzureConfig: AzureOptions{ + Tenant: "common", + }, + OIDCConfig: OIDCOptions{ + InsecureAllowUnverifiedEmail: ptr.To(DefaultInsecureAllowUnverifiedEmail), + InsecureSkipNonce: ptr.To(DefaultInsecureSkipNonce), + SkipDiscovery: ptr.To(DefaultSkipDiscovery), + UserIDClaim: OIDCEmailClaim, // Deprecated: Use OIDCEmailClaim + EmailClaim: OIDCEmailClaim, + GroupsClaim: OIDCGroupsClaim, + AudienceClaims: OIDCAudienceClaims, + ExtraAudiences: []string{}, + }, + }, + } + return providers +} diff --git a/pkg/apis/options/legacy_server.go b/pkg/apis/options/legacy_server.go new file mode 100644 index 00000000..84019793 --- /dev/null +++ b/pkg/apis/options/legacy_server.go @@ -0,0 +1,79 @@ +package options + +import ( + "github.com/spf13/pflag" +) + +type LegacyServer struct { + MetricsAddress string `flag:"metrics-address" cfg:"metrics_address"` + MetricsSecureAddress string `flag:"metrics-secure-address" cfg:"metrics_secure_address"` + MetricsTLSCertFile string `flag:"metrics-tls-cert-file" cfg:"metrics_tls_cert_file"` + MetricsTLSKeyFile string `flag:"metrics-tls-key-file" cfg:"metrics_tls_key_file"` + HTTPAddress string `flag:"http-address" cfg:"http_address"` + HTTPSAddress string `flag:"https-address" cfg:"https_address"` + TLSCertFile string `flag:"tls-cert-file" cfg:"tls_cert_file"` + TLSKeyFile string `flag:"tls-key-file" cfg:"tls_key_file"` + TLSMinVersion string `flag:"tls-min-version" cfg:"tls_min_version"` + TLSCipherSuites []string `flag:"tls-cipher-suite" cfg:"tls_cipher_suites"` +} + +func legacyServerFlagset() *pflag.FlagSet { + flagSet := pflag.NewFlagSet("server", pflag.ExitOnError) + + flagSet.String("metrics-address", "", "the address /metrics will be served on (e.g. \":9100\")") + flagSet.String("metrics-secure-address", "", "the address /metrics will be served on for HTTPS clients (e.g. \":9100\")") + flagSet.String("metrics-tls-cert-file", "", "path to certificate file for secure metrics server") + flagSet.String("metrics-tls-key-file", "", "path to private key file for secure metrics server") + flagSet.String("http-address", "127.0.0.1:4180", "[http://]: or unix:// or fd: (case insensitive) to listen on for HTTP clients") + flagSet.String("https-address", ":443", ": to listen on for HTTPS clients") + flagSet.String("tls-cert-file", "", "path to certificate file") + flagSet.String("tls-key-file", "", "path to private key file") + flagSet.String("tls-min-version", "", "minimal TLS version for HTTPS clients (either \"TLS1.2\" or \"TLS1.3\")") + flagSet.StringSlice("tls-cipher-suite", []string{}, "restricts TLS cipher suites to those listed (e.g. TLS_RSA_WITH_RC4_128_SHA) (may be given multiple times)") + + return flagSet +} + +func (l LegacyServer) convert() (Server, Server) { + appServer := Server{ + BindAddress: l.HTTPAddress, + SecureBindAddress: l.HTTPSAddress, + } + if l.TLSKeyFile != "" || l.TLSCertFile != "" { + appServer.TLS = &TLS{ + Key: &SecretSource{ + FromFile: l.TLSKeyFile, + }, + Cert: &SecretSource{ + FromFile: l.TLSCertFile, + }, + MinVersion: l.TLSMinVersion, + } + if len(l.TLSCipherSuites) != 0 { + appServer.TLS.CipherSuites = l.TLSCipherSuites + } + // Preserve backwards compatibility, only run one server + appServer.BindAddress = "" + } else { + // Disable the HTTPS server if there's no certificates. + // This preserves backwards compatibility. + appServer.SecureBindAddress = "" + } + + metricsServer := Server{ + BindAddress: l.MetricsAddress, + SecureBindAddress: l.MetricsSecureAddress, + } + if l.MetricsTLSKeyFile != "" || l.MetricsTLSCertFile != "" { + metricsServer.TLS = &TLS{ + Key: &SecretSource{ + FromFile: l.MetricsTLSKeyFile, + }, + Cert: &SecretSource{ + FromFile: l.MetricsTLSCertFile, + }, + } + } + + return appServer, metricsServer +} diff --git a/pkg/apis/options/legacy_upstream.go b/pkg/apis/options/legacy_upstream.go new file mode 100644 index 00000000..8843e7da --- /dev/null +++ b/pkg/apis/options/legacy_upstream.go @@ -0,0 +1,105 @@ +package options + +import ( + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" + "github.com/spf13/pflag" +) + +type LegacyUpstreams struct { + FlushInterval time.Duration `flag:"flush-interval" cfg:"flush_interval"` + PassHostHeader bool `flag:"pass-host-header" cfg:"pass_host_header"` + ProxyWebSockets bool `flag:"proxy-websockets" cfg:"proxy_websockets"` + SSLUpstreamInsecureSkipVerify bool `flag:"ssl-upstream-insecure-skip-verify" cfg:"ssl_upstream_insecure_skip_verify"` + Upstreams []string `flag:"upstream" cfg:"upstreams"` + Timeout time.Duration `flag:"upstream-timeout" cfg:"upstream_timeout"` + DisableKeepAlives bool `flag:"disable-keep-alives" cfg:"disable_keep_alives"` +} + +func legacyUpstreamsFlagSet() *pflag.FlagSet { + flagSet := pflag.NewFlagSet("upstreams", pflag.ExitOnError) + + flagSet.Duration("flush-interval", DefaultUpstreamFlushInterval, "period between response flushing when streaming responses") + flagSet.Bool("pass-host-header", true, "pass the request Host Header to upstream") + flagSet.Bool("proxy-websockets", true, "enables WebSocket proxying") + flagSet.Bool("ssl-upstream-insecure-skip-verify", false, "skip validation of certificates presented when using HTTPS upstreams") + flagSet.StringSlice("upstream", []string{}, "the http url(s) of the upstream endpoint, file:// paths for static files or static:// for static response. Routing is based on the path") + flagSet.Duration("upstream-timeout", DefaultUpstreamTimeout, "maximum amount of time the server will wait for a response from the upstream") + flagSet.Bool("disable-keep-alives", false, "disable HTTP keep-alive connections to the upstream server") + + return flagSet +} + +func (l *LegacyUpstreams) convert() (UpstreamConfig, error) { + upstreams := UpstreamConfig{} + + for _, upstreamString := range l.Upstreams { + u, err := url.Parse(upstreamString) + if err != nil { + return UpstreamConfig{}, fmt.Errorf("could not parse upstream %q: %v", upstreamString, err) + } + + if u.Path == "" { + u.Path = "/" + } + + flushInterval := l.FlushInterval + timeout := l.Timeout + upstream := Upstream{ + ID: u.Path, + Path: u.Path, + URI: upstreamString, + InsecureSkipTLSVerify: &l.SSLUpstreamInsecureSkipVerify, + PassHostHeader: &l.PassHostHeader, + ProxyWebSockets: &l.ProxyWebSockets, + FlushInterval: &flushInterval, + Timeout: &timeout, + DisableKeepAlives: &l.DisableKeepAlives, + } + + switch u.Scheme { + case "file": + if u.Fragment != "" { + upstream.ID = u.Fragment + upstream.Path = u.Fragment + // Trim the fragment from the end of the URI + upstream.URI = strings.SplitN(upstreamString, "#", 2)[0] + } + case "static": + responseCode, err := strconv.Atoi(u.Host) + if err != nil { + logger.Errorf("unable to convert %q to int, use default \"200\"", u.Host) + responseCode = 200 + } + upstream.Static = ptr.To(true) + upstream.StaticCode = &responseCode + + // This is not allowed to be empty and must be unique + upstream.ID = upstreamString + + // We only support the root path in the legacy config + upstream.Path = "/" + + // Force defaults compatible with static responses + upstream.URI = "" + upstream.InsecureSkipTLSVerify = ptr.To(false) + upstream.DisableKeepAlives = ptr.To(false) + upstream.PassHostHeader = nil + upstream.ProxyWebSockets = nil + upstream.FlushInterval = nil + upstream.Timeout = nil + case "unix": + upstream.Path = "/" + } + + upstreams.Upstreams = append(upstreams.Upstreams, upstream) + } + + return upstreams, nil +} diff --git a/pkg/apis/options/load_test.go b/pkg/apis/options/load_test.go index 42083f76..e6d7a890 100644 --- a/pkg/apis/options/load_test.go +++ b/pkg/apis/options/load_test.go @@ -46,6 +46,20 @@ var _ = Describe("Load", func() { InsecureOIDCSkipNonce: true, }, + LegacyCookie: LegacyCookie{ + Name: "_oauth2_proxy", + Secret: "", + Domains: nil, + Path: "/", + Expire: time.Duration(168) * time.Hour, + Refresh: time.Duration(0), + Secure: true, + HTTPOnly: true, + SameSite: "", + CSRFPerRequest: false, + CSRFExpire: time.Duration(15) * time.Minute, + }, + Options: Options{ BearerTokenLoginFallback: true, ProxyPrefix: "/oauth2", @@ -53,7 +67,6 @@ var _ = Describe("Load", func() { ReadyPath: "/ready", RealClientIPHeader: "X-Real-IP", ForceHTTPS: false, - Cookie: cookieDefaults(), Session: sessionOptionsDefaults(), Templates: templatesDefaults(), SkipAuthPreflight: false, diff --git a/pkg/apis/options/options.go b/pkg/apis/options/options.go index b57d5aed..02421eee 100644 --- a/pkg/apis/options/options.go +++ b/pkg/apis/options/options.go @@ -35,7 +35,7 @@ type Options struct { HtpasswdFile string `flag:"htpasswd-file" cfg:"htpasswd_file"` HtpasswdUserGroups []string `flag:"htpasswd-user-group" cfg:"htpasswd_user_groups"` - Cookie Cookie `cfg:",squash"` + Cookie Cookie `cfg:",internal"` Session SessionOptions `cfg:",squash"` Logging Logging `cfg:",squash"` Templates Templates `cfg:",squash"` @@ -105,7 +105,6 @@ func NewOptions() *Options { ReadyPath: "/ready", RealClientIPHeader: "X-Real-IP", ForceHTTPS: false, - Cookie: cookieDefaults(), Session: sessionOptionsDefaults(), Templates: templatesDefaults(), SkipAuthPreflight: false, @@ -162,7 +161,6 @@ func NewFlagSet() *pflag.FlagSet { flagSet.String("signature-key", "", "GAP-Signature request signature key (algorithm:secretkey)") flagSet.Bool("gcp-healthchecks", false, "Enable GCP/GKE healthcheck endpoints") - flagSet.AddFlagSet(cookieFlagSet()) flagSet.AddFlagSet(loggingFlagSet()) flagSet.AddFlagSet(templatesFlagSet()) @@ -182,8 +180,9 @@ func (o *Options) EnsureDefaults() { o.InjectResponseHeaders[i].EnsureDefaults() } + o.Cookie.EnsureDefaults() + // TBD: Uncomment as we add EnsureDefaults methods - // o.Cookie.EnsureDefaults() // o.Session.EnsureDefaults() // o.Templates.EnsureDefaults() // o.Logging.EnsureDefaults() diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 6f115f8a..adaa054f 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -337,29 +337,6 @@ type LoginGovOptions struct { PubJWKURL string `yaml:"pubjwkURL,omitempty"` } -// Legacy default providers configuration -func providerDefaults() Providers { - providers := Providers{ - { - Type: "google", - AzureConfig: AzureOptions{ - Tenant: "common", - }, - OIDCConfig: OIDCOptions{ - InsecureAllowUnverifiedEmail: ptr.To(DefaultInsecureAllowUnverifiedEmail), - InsecureSkipNonce: ptr.To(DefaultInsecureSkipNonce), - SkipDiscovery: ptr.To(DefaultSkipDiscovery), - UserIDClaim: OIDCEmailClaim, // Deprecated: Use OIDCEmailClaim - EmailClaim: OIDCEmailClaim, - GroupsClaim: OIDCGroupsClaim, - AudienceClaims: OIDCAudienceClaims, - ExtraAudiences: []string{}, - }, - }, - } - return providers -} - // EnsureDefaults sets any default values for Providers fields. func (p Providers) EnsureDefaults() { for i := range p { diff --git a/pkg/cookies/cookies.go b/pkg/cookies/cookies.go index be5ee451..6424864e 100644 --- a/pkg/cookies/cookies.go +++ b/pkg/cookies/cookies.go @@ -7,24 +7,15 @@ import ( "strings" "time" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" requestutil "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests/util" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" ) -type CookieOptions struct { - Name string - Value string - Domains []string - Expiration time.Duration - SameSite string - Path string - HTTPOnly bool - Secure bool -} - // MakeCookieFromOptions constructs a cookie based on the given *options.CookieOptions, // value and creation time -func MakeCookieFromOptions(req *http.Request, opts *CookieOptions) *http.Cookie { +func MakeCookieFromOptions(req *http.Request, value string, opts *options.Cookie) *http.Cookie { domain := GetCookieDomain(req, opts.Domains) // If nothing matches, create the cookie with the shortest domain if domain == "" && len(opts.Domains) > 0 { @@ -37,17 +28,17 @@ func MakeCookieFromOptions(req *http.Request, opts *CookieOptions) *http.Cookie c := &http.Cookie{ Name: opts.Name, - Value: opts.Value, + Value: value, Path: opts.Path, Domain: domain, - HttpOnly: opts.HTTPOnly, - Secure: opts.Secure, + HttpOnly: ptr.Deref(opts.HTTPOnly, options.DefaultCookieHTTPOnly), + Secure: ptr.Deref(opts.Secure, options.DefaultCookieSecure), SameSite: ParseSameSite(opts.SameSite), } - if opts.Expiration > time.Duration(0) { - c.MaxAge = int(opts.Expiration.Seconds()) - } else if opts.Expiration < time.Duration(0) { + if opts.Expire > time.Duration(0) { + c.MaxAge = int(opts.Expire.Seconds()) + } else if opts.Expire < time.Duration(0) { c.MaxAge = -1 } diff --git a/pkg/cookies/cookies_test.go b/pkg/cookies/cookies_test.go index b67f8a69..f631956b 100644 --- a/pkg/cookies/cookies_test.go +++ b/pkg/cookies/cookies_test.go @@ -5,6 +5,9 @@ import ( "net/http" "time" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" + middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -80,12 +83,14 @@ var _ = Describe("Cookie Tests", func() { Context("MakeCookieFromOptions", func() { type makeCookieFromOptionsTableInput struct { host string - opts CookieOptions + value string + opts options.Cookie now time.Time expectedOutput int } validName := "_oauth2_proxy" + validSecret := "secretthirtytwobytes+abcdefghijk" domains := []string{"www.cookies.test"} now := time.Now() @@ -100,49 +105,55 @@ var _ = Describe("Cookie Tests", func() { ) Expect(err).ToNot(HaveOccurred()) - Expect(MakeCookieFromOptions(req, &in.opts).MaxAge).To(Equal(in.expectedOutput)) + Expect(MakeCookieFromOptions(req, in.value, &in.opts).MaxAge).To(Equal(in.expectedOutput)) }, Entry("persistent cookie", makeCookieFromOptionsTableInput{ - host: "www.cookies.test", - opts: CookieOptions{ - Name: validName, - Value: "1", - Domains: domains, - Expiration: 15 * time.Minute, - SameSite: "", - Path: "", - HTTPOnly: false, - Secure: true, + host: "www.cookies.test", + value: "1", + opts: options.Cookie{ + Name: validName, + Secret: validSecret, + Domains: domains, + Path: "", + Expire: time.Hour, + Refresh: 15 * time.Minute, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), + SameSite: "", }, now: now, expectedOutput: int((15 * time.Minute).Seconds()), }), Entry("persistent cookie to be cleared", makeCookieFromOptionsTableInput{ - host: "www.cookies.test", - opts: CookieOptions{ - Name: validName, - Value: "1", - Domains: domains, - Expiration: time.Hour * -1, - SameSite: "", - Path: "", - HTTPOnly: false, - Secure: true, + host: "www.cookies.test", + value: "1", + opts: options.Cookie{ + Name: validName, + Secret: validSecret, + Domains: domains, + Path: "", + Expire: time.Hour * -1, + Refresh: 15 * time.Minute, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), + SameSite: "", }, now: now, expectedOutput: -1, }), Entry("session cookie", makeCookieFromOptionsTableInput{ - host: "www.cookies.test", - opts: CookieOptions{ - Name: validName, - Value: "1", - Domains: domains, - Expiration: 0, - SameSite: "", - Path: "", - HTTPOnly: false, - Secure: true, + host: "www.cookies.test", + value: "1", + opts: options.Cookie{ + Name: validName, + Secret: validSecret, + Domains: domains, + Path: "", + Expire: 0, + Refresh: 15 * time.Minute, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), + SameSite: "", }, now: now, expectedOutput: expectedMaxAge, diff --git a/pkg/cookies/csrf.go b/pkg/cookies/csrf.go index 2614f378..e239554e 100644 --- a/pkg/cookies/csrf.go +++ b/pkg/cookies/csrf.go @@ -11,6 +11,7 @@ import ( "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" "github.com/vmihailenco/msgpack/v5" ) @@ -96,7 +97,7 @@ func LoadCSRFCookie(req *http.Request, cookieName string, opts *options.Cookie) // build name based on the state func GenerateCookieName(opts *options.Cookie, state string) string { stateSubstring := "" - if opts.CSRFPerRequest { + if ptr.Deref(opts.CSRFPerRequest, options.DefaultCSRFPerRequest) { // csrfCookieName will include a substring of the state to enable multiple csrf cookies // in case of parallel requests stateSubstring = ExtractStateSubstring(state) @@ -134,15 +135,6 @@ func (c *csrf) SetSessionNonce(s *sessions.SessionState) { s.Nonce = c.OIDCNonce } -// getCSRFSameSite get the CSRF same site -func getCSRFSameSite(opts *options.Cookie) string { - sameSite := opts.CSRFSameSite - if sameSite == "" { - sameSite = opts.SameSite - } - return sameSite -} - // SetCookie encodes the CSRF to a signed cookie and sets it on the ResponseWriter func (c *csrf) SetCookie(rw http.ResponseWriter, req *http.Request) (*http.Cookie, error) { encoded, err := c.encodeCookie() @@ -150,18 +142,12 @@ func (c *csrf) SetCookie(rw http.ResponseWriter, req *http.Request) (*http.Cooki return nil, err } - csrfCookieOptions := &CookieOptions{ - Name: c.cookieName(), - Value: encoded, - Domains: c.cookieOpts.Domains, - Expiration: c.cookieOpts.CSRFExpire, - SameSite: getCSRFSameSite(c.cookieOpts), - Path: c.cookieOpts.Path, - HTTPOnly: c.cookieOpts.HTTPOnly, - Secure: c.cookieOpts.Secure, - } + csrfCookieOptions := *c.cookieOpts + csrfCookieOptions.Name = c.cookieName() + csrfCookieOptions.Expire = csrfCookieOptions.CSRFExpire + csrfCookieOptions.SameSite = csrfCookieOptions.CSRFSameSite - cookie := MakeCookieFromOptions(req, csrfCookieOptions) + cookie := MakeCookieFromOptions(req, encoded, &csrfCookieOptions) http.SetCookie(rw, cookie) return cookie, nil @@ -170,7 +156,7 @@ func (c *csrf) SetCookie(rw http.ResponseWriter, req *http.Request) (*http.Cooki // ClearExtraCsrfCookies limits the amount of existing CSRF cookies by deleting // an excess of cookies controlled through the option CSRFPerRequestLimit func ClearExtraCsrfCookies(opts *options.Cookie, rw http.ResponseWriter, req *http.Request) { - if !opts.CSRFPerRequest || opts.CSRFPerRequestLimit <= 0 { + if !ptr.Deref(opts.CSRFPerRequest, options.DefaultCSRFPerRequest) || opts.CSRFPerRequestLimit <= 0 { return } @@ -211,18 +197,12 @@ func ClearExtraCsrfCookies(opts *options.Cookie, rw http.ResponseWriter, req *ht // ClearCookie removes the CSRF cookie func (c *csrf) ClearCookie(rw http.ResponseWriter, req *http.Request) { - csrfCookieOptions := &CookieOptions{ - Name: c.cookieName(), - Value: "", - Domains: c.cookieOpts.Domains, - Expiration: time.Hour * -1, - SameSite: getCSRFSameSite(c.cookieOpts), - Path: c.cookieOpts.Path, - HTTPOnly: c.cookieOpts.HTTPOnly, - Secure: c.cookieOpts.Secure, - } + csrfCookieOptions := *c.cookieOpts + csrfCookieOptions.Name = c.cookieName() + csrfCookieOptions.Expire = time.Hour * -1 + csrfCookieOptions.SameSite = csrfCookieOptions.CSRFSameSite - http.SetCookie(rw, MakeCookieFromOptions(req, csrfCookieOptions)) + http.SetCookie(rw, MakeCookieFromOptions(req, "", &csrfCookieOptions)) } // encodeCookie MessagePack encodes and encrypts the CSRF and then creates a @@ -281,7 +261,7 @@ func unmarshalCSRF(decrypted []byte, opts *options.Cookie, csrfTime time.Time) ( // cookieName returns the CSRF cookie's name func (c *csrf) cookieName() string { stateSubstring := "" - if c.cookieOpts.CSRFPerRequest { + if ptr.Deref(c.cookieOpts.CSRFPerRequest, options.DefaultCSRFPerRequest) { stateSubstring = encryption.HashNonce(c.OAuthState)[0 : csrfStateLength-1] } return csrfCookieName(c.cookieOpts, stateSubstring) diff --git a/pkg/cookies/csrf_per_request_test.go b/pkg/cookies/csrf_per_request_test.go index 59ff0a8a..b13e376d 100644 --- a/pkg/cookies/csrf_per_request_test.go +++ b/pkg/cookies/csrf_per_request_test.go @@ -10,6 +10,7 @@ import ( "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -28,9 +29,9 @@ var _ = Describe("CSRF Cookie with non-fixed name Tests", func() { Domains: []string{cookieDomain}, Path: cookiePath, Expire: time.Hour, - Secure: true, - HTTPOnly: true, - CSRFPerRequest: true, + Secure: ptr.To(true), + HTTPOnly: ptr.To(true), + CSRFPerRequest: ptr.To(true), CSRFExpire: time.Duration(5) * time.Minute, } @@ -216,18 +217,13 @@ var _ = Describe("CSRF Cookie with non-fixed name Tests", func() { for _, csrf := range []*csrf{privateCSRF1, privateCSRF2, privateCSRF3} { encoded, err := csrf.encodeCookie() Expect(err).ToNot(HaveOccurred()) - csrfCookieOptions := &CookieOptions{ - Name: csrf.cookieName(), - Value: encoded, - Domains: csrf.cookieOpts.Domains, - Expiration: csrf.cookieOpts.CSRFExpire, - SameSite: getCSRFSameSite(csrf.cookieOpts), - Path: csrf.cookieOpts.Path, - HTTPOnly: csrf.cookieOpts.HTTPOnly, - Secure: csrf.cookieOpts.Secure, - } - cookie := MakeCookieFromOptions(req, csrfCookieOptions) + csrfCookieOpts := *csrf.cookieOpts + csrfCookieOpts.Name = csrf.cookieName() + csrfCookieOpts.Expire = csrfCookieOpts.CSRFExpire + csrfCookieOpts.SameSite = csrfCookieOpts.CSRFSameSite + + cookie := MakeCookieFromOptions(req, encoded, &csrfCookieOpts) cookies = append(cookies, fmt.Sprintf("%v=%v", cookie.Name, cookie.Value)) } diff --git a/pkg/cookies/csrf_test.go b/pkg/cookies/csrf_test.go index 3182f663..c16fc7cc 100644 --- a/pkg/cookies/csrf_test.go +++ b/pkg/cookies/csrf_test.go @@ -10,6 +10,7 @@ import ( "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -29,9 +30,9 @@ var _ = Describe("CSRF Cookie Tests", func() { Domains: []string{cookieDomain}, Path: cookiePath, Expire: time.Hour, - Secure: true, - HTTPOnly: true, - CSRFPerRequest: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(true), + CSRFPerRequest: ptr.To(false), CSRFExpire: time.Hour, } @@ -307,9 +308,9 @@ var _ = Describe("CSRF Cookie Tests", func() { Domains: []string{cookieDomain}, Path: cookiePath, Expire: time.Hour, - Secure: true, - HTTPOnly: true, - CSRFPerRequest: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(true), + CSRFPerRequest: ptr.To(false), CSRFExpire: time.Hour, } }) diff --git a/pkg/sessions/cookie/session_store.go b/pkg/sessions/cookie/session_store.go index a4da3734..35a5fa75 100644 --- a/pkg/sessions/cookie/session_store.go +++ b/pkg/sessions/cookie/session_store.go @@ -76,17 +76,11 @@ func (s *SessionStore) Clear(rw http.ResponseWriter, req *http.Request) error { for _, c := range req.Cookies() { if cookieNameRegex.MatchString(c.Name) { - sessionCookieOptions := &pkgcookies.CookieOptions{ - Name: c.Name, - Value: "", - Domains: s.Cookie.Domains, - Expiration: time.Hour * -1, - SameSite: s.Cookie.SameSite, - Path: s.Cookie.Path, - HTTPOnly: s.Cookie.HTTPOnly, - Secure: s.Cookie.Secure, - } - clearCookie := pkgcookies.MakeCookieFromOptions(req, sessionCookieOptions) + sessionCookieOptions := *s.Cookie + sessionCookieOptions.Name = c.Name + sessionCookieOptions.Expire = time.Hour * -1 + + clearCookie := pkgcookies.MakeCookieFromOptions(req, "", &sessionCookieOptions) http.SetCookie(rw, clearCookie) } @@ -142,17 +136,7 @@ func (s *SessionStore) makeSessionCookie(req *http.Request, value []byte, now ti return nil, err } } - sessionCookieOptions := &pkgcookies.CookieOptions{ - Name: s.Cookie.Name, - Value: strValue, - Domains: s.Cookie.Domains, - Expiration: s.Cookie.Expire, - SameSite: s.Cookie.SameSite, - Path: s.Cookie.Path, - HTTPOnly: s.Cookie.HTTPOnly, - Secure: s.Cookie.Secure, - } - c := pkgcookies.MakeCookieFromOptions(req, sessionCookieOptions) + c := pkgcookies.MakeCookieFromOptions(req, strValue, s.Cookie) if len(c.String()) > maxCookieLength { return splitCookie(c), nil } diff --git a/pkg/sessions/persistence/ticket.go b/pkg/sessions/persistence/ticket.go index c955143a..0a2fd546 100644 --- a/pkg/sessions/persistence/ticket.go +++ b/pkg/sessions/persistence/ticket.go @@ -221,17 +221,10 @@ func (t *ticket) setCookie(rw http.ResponseWriter, req *http.Request, s *session // clearCookie removes any cookies that would be where this ticket // would set them func (t *ticket) clearCookie(rw http.ResponseWriter, req *http.Request) { - cookieOptions := &cookies.CookieOptions{ - Name: t.options.Name, - Value: "", - Domains: t.options.Domains, - Expiration: time.Hour * -1, - SameSite: t.options.SameSite, - Path: t.options.Path, - HTTPOnly: t.options.HTTPOnly, - Secure: t.options.Secure, - } - http.SetCookie(rw, cookies.MakeCookieFromOptions(req, cookieOptions)) + cookieOpt := *t.options + cookieOpt.Expire = time.Hour * -1 + + http.SetCookie(rw, cookies.MakeCookieFromOptions(req, "", &cookieOpt)) } // makeCookie makes a cookie, signing the value if present @@ -248,18 +241,10 @@ func (t *ticket) makeCookie(req *http.Request, value string, expires time.Durati } } - cookieOptions := &cookies.CookieOptions{ - Name: t.options.Name, - Value: value, - Domains: t.options.Domains, - Expiration: expires, - SameSite: t.options.SameSite, - Path: t.options.Path, - HTTPOnly: t.options.HTTPOnly, - Secure: t.options.Secure, - } + cookieOpt := *t.options + cookieOpt.Expire = expires - return cookies.MakeCookieFromOptions(req, cookieOptions), nil + return cookies.MakeCookieFromOptions(req, value, &cookieOpt), nil } // makeCipher makes a AES-GCM cipher out of the ticket's secret diff --git a/pkg/sessions/session_store_test.go b/pkg/sessions/session_store_test.go index 0db45f78..3b811903 100644 --- a/pkg/sessions/session_store_test.go +++ b/pkg/sessions/session_store_test.go @@ -12,6 +12,7 @@ import ( sessionscookie "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/sessions/cookie" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/sessions/persistence" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/sessions/redis" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -44,8 +45,8 @@ var _ = Describe("NewSessionStore", func() { Path: "/", Expire: time.Duration(168) * time.Hour, Refresh: time.Duration(1) * time.Hour, - Secure: true, - HTTPOnly: true, + Secure: ptr.To(true), + HTTPOnly: ptr.To(true), SameSite: "", } }) diff --git a/pkg/sessions/tests/session_store_tests.go b/pkg/sessions/tests/session_store_tests.go index dd678042..2f38b9eb 100644 --- a/pkg/sessions/tests/session_store_tests.go +++ b/pkg/sessions/tests/session_store_tests.go @@ -13,6 +13,7 @@ import ( sessionsapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" cookiesapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/cookies" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -68,8 +69,8 @@ func RunSessionStoreTests(newSS NewSessionStoreFunc, persistentFastForward Persi Path: "/", Expire: time.Duration(168) * time.Hour, Refresh: time.Duration(1) * time.Hour, - Secure: true, - HTTPOnly: true, + Secure: ptr.To(true), + HTTPOnly: ptr.To(true), SameSite: "", Secret: string(cookieSecret), } @@ -117,8 +118,8 @@ func RunSessionStoreTests(newSS NewSessionStoreFunc, persistentFastForward Persi Path: "/path", Expire: time.Duration(72) * time.Hour, Refresh: time.Duration(2) * time.Hour, - Secure: false, - HTTPOnly: false, + Secure: ptr.To(false), + HTTPOnly: ptr.To(false), Domains: []string{"example.com"}, SameSite: "strict", Secret: string(cookieSecret), @@ -149,8 +150,8 @@ func RunSessionStoreTests(newSS NewSessionStoreFunc, persistentFastForward Persi Path: "/", Expire: time.Duration(168) * time.Hour, Refresh: time.Duration(1) * time.Hour, - Secure: true, - HTTPOnly: true, + Secure: ptr.To(true), + HTTPOnly: ptr.To(true), SameSite: "", Secret: "", SecretFile: tmpfile.Name(), @@ -208,13 +209,13 @@ func CheckCookieOptions(in *testInput) { It("have the correct HTTPOnly set", func() { for _, cookie := range cookies { - Expect(cookie.HttpOnly).To(Equal(in.cookieOpts.HTTPOnly)) + Expect(cookie.HttpOnly).To(Equal(*in.cookieOpts.HTTPOnly)) } }) It("have the correct secure set", func() { for _, cookie := range cookies { - Expect(cookie.Secure).To(Equal(in.cookieOpts.Secure)) + Expect(cookie.Secure).To(Equal(*in.cookieOpts.Secure)) } }) @@ -422,17 +423,7 @@ func SessionStoreInterfaceTests(in *testInput) { broken := "BrokenSessionFromADifferentSessionImplementation" value, err := encryption.SignedValue(in.cookieOpts.Secret, in.cookieOpts.Name, []byte(broken), time.Now()) Expect(err).ToNot(HaveOccurred()) - cookieOptions := &cookiesapi.CookieOptions{ - Name: in.cookieOpts.Name, - Value: value, - Domains: in.cookieOpts.Domains, - Expiration: in.cookieOpts.Expire, - SameSite: in.cookieOpts.SameSite, - Path: in.cookieOpts.Path, - HTTPOnly: in.cookieOpts.HTTPOnly, - Secure: in.cookieOpts.Secure, - } - cookie := cookiesapi.MakeCookieFromOptions(in.request, cookieOptions) + cookie := cookiesapi.MakeCookieFromOptions(in.request, value, in.cookieOpts) in.request.AddCookie(cookie) err = in.ss().Save(in.response, in.request, in.session) diff --git a/pkg/validation/cookie_test.go b/pkg/validation/cookie_test.go index d11134da..95e3203e 100644 --- a/pkg/validation/cookie_test.go +++ b/pkg/validation/cookie_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" . "github.com/onsi/gomega" ) @@ -66,8 +67,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: time.Hour, Refresh: 15 * time.Minute, - Secure: true, - HTTPOnly: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), SameSite: "", }, errStrings: []string{}, @@ -81,8 +82,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: time.Hour, Refresh: 15 * time.Minute, - Secure: true, - HTTPOnly: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), SameSite: "", }, errStrings: []string{ @@ -98,8 +99,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: time.Hour, Refresh: 15 * time.Minute, - Secure: true, - HTTPOnly: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), SameSite: "", }, errStrings: []string{ @@ -115,8 +116,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: time.Hour, Refresh: 15 * time.Minute, - Secure: true, - HTTPOnly: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), SameSite: "", }, errStrings: []string{}, @@ -130,8 +131,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: time.Hour, Refresh: 15 * time.Minute, - Secure: true, - HTTPOnly: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), SameSite: "", }, errStrings: []string{ @@ -147,8 +148,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: time.Hour, Refresh: 15 * time.Minute, - Secure: true, - HTTPOnly: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), SameSite: "", }, errStrings: []string{ @@ -164,8 +165,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: time.Hour, Refresh: 15 * time.Minute, - Secure: true, - HTTPOnly: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), SameSite: "", }, errStrings: []string{ @@ -181,8 +182,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: 15 * time.Minute, Refresh: time.Hour, - Secure: true, - HTTPOnly: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), SameSite: "", }, errStrings: []string{ @@ -198,8 +199,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: time.Hour, Refresh: 15 * time.Minute, - Secure: true, - HTTPOnly: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), SameSite: "none", }, errStrings: []string{}, @@ -213,8 +214,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: time.Hour, Refresh: 15 * time.Minute, - Secure: true, - HTTPOnly: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), SameSite: "none", }, errStrings: []string{}, @@ -228,8 +229,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: time.Hour, Refresh: 15 * time.Minute, - Secure: true, - HTTPOnly: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), SameSite: "none", }, errStrings: []string{}, @@ -243,8 +244,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: time.Hour, Refresh: 15 * time.Minute, - Secure: true, - HTTPOnly: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), SameSite: "invalid", }, errStrings: []string{ @@ -260,8 +261,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: 15 * time.Minute, Refresh: time.Hour, - Secure: true, - HTTPOnly: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), SameSite: "invalid", }, errStrings: []string{ @@ -280,8 +281,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: 0, Refresh: 15 * time.Minute, - Secure: true, - HTTPOnly: false, + Secure: ptr.To(true), + HTTPOnly: ptr.To(false), SameSite: "", }, errStrings: []string{}, @@ -296,8 +297,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: 24 * time.Hour, Refresh: 0, - Secure: true, - HTTPOnly: true, + Secure: ptr.To(true), + HTTPOnly: ptr.To(true), SameSite: "", }, errStrings: []string{}, @@ -312,8 +313,8 @@ func TestValidateCookie(t *testing.T) { Path: "", Expire: 24 * time.Hour, Refresh: 0, - Secure: true, - HTTPOnly: true, + Secure: ptr.To(true), + HTTPOnly: ptr.To(true), SameSite: "", }, errStrings: []string{"could not read cookie secret file: /nonexistent/file.txt"}, diff --git a/pkg/validation/options.go b/pkg/validation/options.go index 13ce2e0b..ec0e05f0 100644 --- a/pkg/validation/options.go +++ b/pkg/validation/options.go @@ -74,7 +74,7 @@ func Validate(o *options.Options) error { var redirectURL *url.URL redirectURL, msgs = parseURL(o.RawRedirectURL, "redirect", msgs) o.SetRedirectURL(redirectURL) - if o.RawRedirectURL == "" && !o.Cookie.Secure && !o.ReverseProxy { + if o.RawRedirectURL == "" && !ptr.Deref(o.Cookie.Secure, options.DefaultCookieSecure) && !o.ReverseProxy { logger.Print("WARNING: no explicit redirect URL: redirects will default to insecure HTTP") } diff --git a/pkg/validation/options_test.go b/pkg/validation/options_test.go index 5ea748c1..cb13ee82 100644 --- a/pkg/validation/options_test.go +++ b/pkg/validation/options_test.go @@ -32,6 +32,7 @@ func testOptions() *options.Options { o.Providers[0].ClientID = clientID o.Providers[0].ClientSecret = clientSecret o.EmailDomains = []string{"*"} + o.EnsureDefaults() return o } @@ -45,6 +46,8 @@ func errorMsg(msgs []string) string { func TestNewOptions(t *testing.T) { o := options.NewOptions() o.EmailDomains = []string{"*"} + o.EnsureDefaults() + err := Validate(o) assert.NotEqual(t, nil, err)