diff --git a/CHANGELOG.md b/CHANGELOG.md index a51aa773..f131c6c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ ## Changes since v7.8.1 +- [#2918](https://github.com/oauth2-proxy/oauth2-proxy/issues/2918) feat: add --bearer-token-login-fallback (@carillonator) - [#2927](https://github.com/oauth2-proxy/oauth2-proxy/pull/2927) chore(deps/build): bump golang to 1.23 and use go.mod as single point of truth for all build files (@tuunit) - [#2697](https://github.com/oauth2-proxy/oauth2-proxy/pull/2697) Use `Max-Age` instead of `Expires` for cookie expiration (@matpen-wi) - [#2969](https://github.com/oauth2-proxy/oauth2-proxy/pull/2969) Update golang.org/x/oauth2 to v0.27.0 to address CVE-2025-22868 (@dsymonds) diff --git a/docs/docs/configuration/overview.md b/docs/docs/configuration/overview.md index 52ead105..33f87072 100644 --- a/docs/docs/configuration/overview.md +++ b/docs/docs/configuration/overview.md @@ -191,8 +191,9 @@ Provider specific options can be found on their respective subpages. | Flag / Config Field | Type | Description | Default | | ------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | flag: `--allow-query-semicolons`
toml: `allow_query_semicolons` | bool | allow the use of semicolons in query args ([required for some legacy applications](https://github.com/golang/go/issues/25192)) | `false` | -| flag: `--api-route`
toml: `api_routes` | string \| list | return HTTP 401 instead of redirecting to authentication server if token is not valid. Format: path_regex | | +| flag: `--api-route`
toml: `api_routes` | string \| list | Requests to these paths must already be authenticated with a cookie, or a JWT if `--skip-jwt-bearer-tokens` is set. No redirect to login will be done. Return 401 if not. Format: path_regex | | | flag: `--authenticated-emails-file`
toml: `authenticated_emails_file` | string | authenticate against emails via file (one per line) | | +| flag: `--bearer-token-login-fallback`
toml: `bearer_token_login_fallback` | bool | if `--skip-jwt-bearer-tokens` is set, if a request includes an invalid JWT (expired, malformed, missing required audiences, etc), fall back to normal login redirect as if the token were not sent at all. If false, respond 403 | true | | flag: `--email-domain`
toml: `email_domains` | string \| list | authenticate emails with the specified domain (may be given multiple times). Use `*` to authenticate any email | | | flag: `--encode-state`
toml: `encode_state` | bool | encode the state parameter as UrlEncodedBase64 | false | | flag: `--extra-jwt-issuers`
toml: `extra_jwt_issuers` | string | if `--skip-jwt-bearer-tokens` is set, a list of extra JWT `issuer=audience` (see a token's `iss`, `aud` fields) pairs (where the issuer URL has a `.well-known/openid-configuration` or a `.well-known/jwks.json`) | | diff --git a/oauthproxy.go b/oauthproxy.go index ca9d4e97..b85c89b4 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -163,6 +163,10 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr for _, issuer := range opts.ExtraJwtIssuers { logger.Printf("Skipping JWT tokens from extra JWT issuer: %q", issuer) } + if !opts.BearerTokenLoginFallback { + logger.Println("Denying requests with invalid JWT tokens") + } + } redirectURL := opts.GetRedirectURL() if redirectURL.Path == "" { @@ -402,7 +406,7 @@ func buildSessionChain(opts *options.Options, provider providers.Provider, sessi middlewareapi.CreateTokenToSessionFunc(verifier.Verify)) } - chain = chain.Append(middleware.NewJwtSessionLoader(sessionLoaders)) + chain = chain.Append(middleware.NewJwtSessionLoader(sessionLoaders, opts.BearerTokenLoginFallback)) } if validator != nil { diff --git a/pkg/apis/options/load_test.go b/pkg/apis/options/load_test.go index 32d52fb8..4265156f 100644 --- a/pkg/apis/options/load_test.go +++ b/pkg/apis/options/load_test.go @@ -47,16 +47,17 @@ var _ = Describe("Load", func() { }, Options: Options{ - ProxyPrefix: "/oauth2", - PingPath: "/ping", - ReadyPath: "/ready", - RealClientIPHeader: "X-Real-IP", - ForceHTTPS: false, - Cookie: cookieDefaults(), - Session: sessionOptionsDefaults(), - Templates: templatesDefaults(), - SkipAuthPreflight: false, - Logging: loggingDefaults(), + BearerTokenLoginFallback: true, + ProxyPrefix: "/oauth2", + PingPath: "/ping", + ReadyPath: "/ready", + RealClientIPHeader: "X-Real-IP", + ForceHTTPS: false, + Cookie: cookieDefaults(), + Session: sessionOptionsDefaults(), + Templates: templatesDefaults(), + SkipAuthPreflight: false, + Logging: loggingDefaults(), }, } diff --git a/pkg/apis/options/options.go b/pkg/apis/options/options.go index 2982ca74..8fa72c7c 100644 --- a/pkg/apis/options/options.go +++ b/pkg/apis/options/options.go @@ -52,17 +52,18 @@ type Options struct { Providers Providers `cfg:",internal"` - APIRoutes []string `flag:"api-route" cfg:"api_routes"` - SkipAuthRegex []string `flag:"skip-auth-regex" cfg:"skip_auth_regex"` - SkipAuthRoutes []string `flag:"skip-auth-route" cfg:"skip_auth_routes"` - SkipJwtBearerTokens bool `flag:"skip-jwt-bearer-tokens" cfg:"skip_jwt_bearer_tokens"` - ExtraJwtIssuers []string `flag:"extra-jwt-issuers" cfg:"extra_jwt_issuers"` - SkipProviderButton bool `flag:"skip-provider-button" cfg:"skip_provider_button"` - SSLInsecureSkipVerify bool `flag:"ssl-insecure-skip-verify" cfg:"ssl_insecure_skip_verify"` - SkipAuthPreflight bool `flag:"skip-auth-preflight" cfg:"skip_auth_preflight"` - ForceJSONErrors bool `flag:"force-json-errors" cfg:"force_json_errors"` - EncodeState bool `flag:"encode-state" cfg:"encode_state"` - AllowQuerySemicolons bool `flag:"allow-query-semicolons" cfg:"allow_query_semicolons"` + APIRoutes []string `flag:"api-route" cfg:"api_routes"` + SkipAuthRegex []string `flag:"skip-auth-regex" cfg:"skip_auth_regex"` + SkipAuthRoutes []string `flag:"skip-auth-route" cfg:"skip_auth_routes"` + SkipJwtBearerTokens bool `flag:"skip-jwt-bearer-tokens" cfg:"skip_jwt_bearer_tokens"` + BearerTokenLoginFallback bool `flag:"bearer-token-login-fallback" cfg:"bearer_token_login_fallback"` + ExtraJwtIssuers []string `flag:"extra-jwt-issuers" cfg:"extra_jwt_issuers"` + SkipProviderButton bool `flag:"skip-provider-button" cfg:"skip_provider_button"` + SSLInsecureSkipVerify bool `flag:"ssl-insecure-skip-verify" cfg:"ssl_insecure_skip_verify"` + SkipAuthPreflight bool `flag:"skip-auth-preflight" cfg:"skip_auth_preflight"` + ForceJSONErrors bool `flag:"force-json-errors" cfg:"force_json_errors"` + EncodeState bool `flag:"encode-state" cfg:"encode_state"` + AllowQuerySemicolons bool `flag:"allow-query-semicolons" cfg:"allow_query_semicolons"` SignatureKey string `flag:"signature-key" cfg:"signature_key"` GCPHealthChecks bool `flag:"gcp-healthchecks" cfg:"gcp_healthchecks"` @@ -97,17 +98,18 @@ func (o *Options) SetRealClientIPParser(s ipapi.RealClientIPParser) { o.re // NewOptions constructs a new Options with defaulted values func NewOptions() *Options { return &Options{ - ProxyPrefix: "/oauth2", - Providers: providerDefaults(), - PingPath: "/ping", - ReadyPath: "/ready", - RealClientIPHeader: "X-Real-IP", - ForceHTTPS: false, - Cookie: cookieDefaults(), - Session: sessionOptionsDefaults(), - Templates: templatesDefaults(), - SkipAuthPreflight: false, - Logging: loggingDefaults(), + BearerTokenLoginFallback: true, + ProxyPrefix: "/oauth2", + Providers: providerDefaults(), + PingPath: "/ping", + ReadyPath: "/ready", + RealClientIPHeader: "X-Real-IP", + ForceHTTPS: false, + Cookie: cookieDefaults(), + Session: sessionOptionsDefaults(), + Templates: templatesDefaults(), + SkipAuthPreflight: false, + Logging: loggingDefaults(), } } @@ -128,6 +130,7 @@ func NewFlagSet() *pflag.FlagSet { flagSet.Bool("skip-auth-preflight", false, "will skip authentication for OPTIONS requests") flagSet.Bool("ssl-insecure-skip-verify", false, "skip validation of certificates presented when using HTTPS providers") flagSet.Bool("skip-jwt-bearer-tokens", false, "will skip requests that have verified JWT bearer tokens (default false)") + flagSet.Bool("bearer-token-login-fallback", true, "if skip-jwt-bearer-tokens is set, fall back to normal login redirect with an invalid JWT. If false, 403 instead") flagSet.Bool("force-json-errors", false, "will force JSON errors instead of HTTP error pages or redirects") flagSet.Bool("encode-state", false, "will encode oauth state with base64") flagSet.Bool("allow-query-semicolons", false, "allow the use of semicolons in query args") diff --git a/pkg/middleware/jwt_session.go b/pkg/middleware/jwt_session.go index 026b6ad8..790eb8b2 100644 --- a/pkg/middleware/jwt_session.go +++ b/pkg/middleware/jwt_session.go @@ -15,10 +15,11 @@ import ( const jwtRegexFormat = `^ey[a-zA-Z0-9_-]*\.ey[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]+$` -func NewJwtSessionLoader(sessionLoaders []middlewareapi.TokenToSessionFunc) alice.Constructor { +func NewJwtSessionLoader(sessionLoaders []middlewareapi.TokenToSessionFunc, bearerTokenLoginFallback bool) alice.Constructor { js := &jwtSessionLoader{ - jwtRegex: regexp.MustCompile(jwtRegexFormat), - sessionLoaders: sessionLoaders, + jwtRegex: regexp.MustCompile(jwtRegexFormat), + sessionLoaders: sessionLoaders, + denyInvalidJWTs: !bearerTokenLoginFallback, } return js.loadSession } @@ -26,14 +27,16 @@ func NewJwtSessionLoader(sessionLoaders []middlewareapi.TokenToSessionFunc) alic // jwtSessionLoader is responsible for loading sessions from JWTs in // Authorization headers. type jwtSessionLoader struct { - jwtRegex *regexp.Regexp - sessionLoaders []middlewareapi.TokenToSessionFunc + jwtRegex *regexp.Regexp + sessionLoaders []middlewareapi.TokenToSessionFunc + denyInvalidJWTs bool } // loadSession attempts to load a session from a JWT stored in an Authorization // header within the request. // If no authorization header is found, or the header is invalid, no session // will be loaded and the request will be passed to the next handler. +// Or if the JWT is invalid and denyInvalidJWTs, return 403 now. // If a session was loaded by a previous handler, it will not be replaced. func (j *jwtSessionLoader) loadSession(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { @@ -49,6 +52,10 @@ func (j *jwtSessionLoader) loadSession(next http.Handler) http.Handler { session, err := j.getJwtSession(req) if err != nil { logger.Errorf("Error retrieving session from token in Authorization header: %v", err) + if j.denyInvalidJWTs { + http.Error(rw, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } } // Add the session to the scope if it was found @@ -58,7 +65,7 @@ func (j *jwtSessionLoader) loadSession(next http.Handler) http.Handler { } // getJwtSession loads a session based on a JWT token in the authorization header. -// (see the config options skip-jwt-bearer-tokens and extra-jwt-issuers) +// (see the config options skip-jwt-bearer-tokens, extra-jwt-issuers, and bearer-token-login-fallback) func (j *jwtSessionLoader) getJwtSession(req *http.Request) (*sessionsapi.SessionState, error) { auth := req.Header.Get("Authorization") if auth == "" { diff --git a/pkg/middleware/jwt_session_test.go b/pkg/middleware/jwt_session_test.go index f7051a64..12f30f5c 100644 --- a/pkg/middleware/jwt_session_test.go +++ b/pkg/middleware/jwt_session_test.go @@ -92,6 +92,7 @@ Nnc3a3lGVWFCNUMxQnNJcnJMTWxka1dFaHluYmI4Ongtb2F1dGgtYmFzaWM=` authorizationHeader string existingSession *sessionsapi.SessionState expectedSession *sessionsapi.SessionState + expectedStatus int } DescribeTable("with an authorization header", @@ -114,12 +115,13 @@ Nnc3a3lGVWFCNUMxQnNJcnJMTWxka1dFaHluYmI4Ongtb2F1dGgtYmFzaWM=` // Create the handler with a next handler that will capture the session // from the scope var gotSession *sessionsapi.SessionState - handler := NewJwtSessionLoader(sessionLoaders)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := NewJwtSessionLoader(sessionLoaders, true)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotSession = middlewareapi.GetRequestScope(r).Session })) handler.ServeHTTP(rw, req) Expect(gotSession).To(Equal(in.expectedSession)) + Expect(rw.Code).To(Equal(200)) }, Entry("", jwtSessionLoaderTableInput{ authorizationHeader: "", @@ -163,6 +165,83 @@ Nnc3a3lGVWFCNUMxQnNJcnJMTWxka1dFaHluYmI4Ongtb2F1dGgtYmFzaWM=` }), ) + DescribeTable("with an authorization header, denyInvalidJWTs", + func(in jwtSessionLoaderTableInput) { + scope := &middlewareapi.RequestScope{ + Session: in.existingSession, + } + + // Set up the request with the authorization header and a request scope + req := httptest.NewRequest("", "/", nil) + req.Header.Set("Authorization", in.authorizationHeader) + req = middlewareapi.AddRequestScope(req, scope) + + rw := httptest.NewRecorder() + + sessionLoaders := []middlewareapi.TokenToSessionFunc{ + middlewareapi.CreateTokenToSessionFunc(verifier), + } + + // Create the handler with a next handler that will capture the session + // from the scope + var gotSession *sessionsapi.SessionState + handler := NewJwtSessionLoader(sessionLoaders, false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotSession = middlewareapi.GetRequestScope(r).Session + })) + handler.ServeHTTP(rw, req) + + Expect(gotSession).To(Equal(in.expectedSession)) + Expect(rw.Code).To(Equal(in.expectedStatus)) + }, + Entry("", jwtSessionLoaderTableInput{ + authorizationHeader: "", + existingSession: nil, + expectedSession: nil, + expectedStatus: 200, + }), + Entry("abcdef", jwtSessionLoaderTableInput{ + authorizationHeader: "abcdef", + existingSession: nil, + expectedSession: nil, + expectedStatus: 403, + }), + Entry("abcdef (with existing session)", jwtSessionLoaderTableInput{ + authorizationHeader: "abcdef", + existingSession: &sessionsapi.SessionState{User: "user"}, + expectedSession: &sessionsapi.SessionState{User: "user"}, + expectedStatus: 200, + }), + Entry("Bearer ", jwtSessionLoaderTableInput{ + authorizationHeader: fmt.Sprintf("Bearer %s", verifiedToken), + existingSession: nil, + expectedSession: verifiedSession, + expectedStatus: 200, + }), + Entry("Bearer ", jwtSessionLoaderTableInput{ + authorizationHeader: fmt.Sprintf("Bearer %s", nonVerifiedToken), + existingSession: nil, + expectedSession: nil, + expectedStatus: 403, + }), + Entry("Bearer (with existing session)", jwtSessionLoaderTableInput{ + authorizationHeader: fmt.Sprintf("Bearer %s", verifiedToken), + existingSession: &sessionsapi.SessionState{User: "user"}, + expectedSession: &sessionsapi.SessionState{User: "user"}, + expectedStatus: 200, + }), + Entry("Basic Base64(:) (No password)", jwtSessionLoaderTableInput{ + authorizationHeader: "Basic ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6", + existingSession: nil, + expectedSession: nil, + expectedStatus: 403, + }), + Entry("Basic Base64(:x-oauth-basic) (Sentinel password)", jwtSessionLoaderTableInput{ + authorizationHeader: fmt.Sprintf("Basic %s", verifiedTokenXOAuthBasicBase64), + existingSession: nil, + expectedSession: verifiedSession, + expectedStatus: 200, + }), + ) }) Context("getJWTSession", func() {