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() {