git.iko.soy/typeable/oauth2-proxy
on master help sign in
Add --bearer-token-login-fallback option (#2924)

* add --deny-invalid-bearer-tokens * update changelog * PR feedback, update api-routes description * update --api-routes description * revert load_test fix that I needed locally --------- Co-authored-by: Justin Ryan <j.ryan@mwam.com>

commit8abdbb5a18c6bef481d7b35fc6d90762cf8d7fec authorJustin Ryan <jryan@verticalresponse.com> dateMon 21 Apr 2025, 08:40 (1 year ago) parentbb6ff4e
7 files changed+137−41
CHANGELOG.md+1 docs/docs/configuration/overview.md+2−1 oauthproxy.go+5−1 pkg/apis/options/load_test.go+11−10 pkg/apis/options/options.go+25−22 pkg/middleware/jwt_session.go+13−6 pkg/middleware/jwt_session_test.go+80−1
CHANGELOG.md+1
@@ -32,6 +32,7 @@
3232
3333## Changes since v7.8.1
3434
35+- [#2918](https://github.com/oauth2-proxy/oauth2-proxy/issues/2918) feat: add --bearer-token-login-fallback (@carillonator)
3536- [#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)
3637- [#2697](https://github.com/oauth2-proxy/oauth2-proxy/pull/2697) Use `Max-Age` instead of `Expires` for cookie expiration (@matpen-wi)
3738- [#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)
docs/docs/configuration/overview.md+2−1
@@ -191,8 +191,9 @@ Provider specific options can be found on their respective subpages.
191191| Flag / Config Field | Type | Description | Default |
192192| ------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
193193| flag: `--allow-query-semicolons`<br/>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` |
194−| flag: `--api-route`<br/>toml: `api_routes` | string \| list | return HTTP 401 instead of redirecting to authentication server if token is not valid. Format: path_regex | |
194+| flag: `--api-route`<br/>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 | |
195195| flag: `--authenticated-emails-file`<br/>toml: `authenticated_emails_file` | string | authenticate against emails via file (one per line) | |
196+| flag: `--bearer-token-login-fallback`<br/>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 |
196197| flag: `--email-domain`<br/>toml: `email_domains` | string \| list | authenticate emails with the specified domain (may be given multiple times). Use `*` to authenticate any email | |
197198| flag: `--encode-state`<br/>toml: `encode_state` | bool | encode the state parameter as UrlEncodedBase64 | false |
198199| flag: `--extra-jwt-issuers`<br/>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`) | |
oauthproxy.go+5−1
@@ -163,6 +163,10 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
163163 for _, issuer := range opts.ExtraJwtIssuers {
164164 logger.Printf("Skipping JWT tokens from extra JWT issuer: %q", issuer)
165165 }
166+ if !opts.BearerTokenLoginFallback {
167+ logger.Println("Denying requests with invalid JWT tokens")
168+ }
169+
166170 }
167171 redirectURL := opts.GetRedirectURL()
168172 if redirectURL.Path == "" {
@@ -402,7 +406,7 @@ func buildSessionChain(opts *options.Options, provider providers.Provider, sessi
402406 middlewareapi.CreateTokenToSessionFunc(verifier.Verify))
403407 }
404408
405− chain = chain.Append(middleware.NewJwtSessionLoader(sessionLoaders))
409+ chain = chain.Append(middleware.NewJwtSessionLoader(sessionLoaders, opts.BearerTokenLoginFallback))
406410 }
407411
408412 if validator != nil {
pkg/apis/options/load_test.go+11−10
@@ -47,16 +47,17 @@ var _ = Describe("Load", func() {
4747 },
4848
4949 Options: Options{
50− ProxyPrefix: "/oauth2",
51− PingPath: "/ping",
52− ReadyPath: "/ready",
53− RealClientIPHeader: "X-Real-IP",
54− ForceHTTPS: false,
55− Cookie: cookieDefaults(),
56− Session: sessionOptionsDefaults(),
57− Templates: templatesDefaults(),
58− SkipAuthPreflight: false,
59− Logging: loggingDefaults(),
50+ BearerTokenLoginFallback: true,
51+ ProxyPrefix: "/oauth2",
52+ PingPath: "/ping",
53+ ReadyPath: "/ready",
54+ RealClientIPHeader: "X-Real-IP",
55+ ForceHTTPS: false,
56+ Cookie: cookieDefaults(),
57+ Session: sessionOptionsDefaults(),
58+ Templates: templatesDefaults(),
59+ SkipAuthPreflight: false,
60+ Logging: loggingDefaults(),
6061 },
6162 }
6263
pkg/apis/options/options.go+25−22
@@ -52,17 +52,18 @@ type Options struct {
5252
5353 Providers Providers `cfg:",internal"`
5454
55− APIRoutes []string `flag:"api-route" cfg:"api_routes"`
56− SkipAuthRegex []string `flag:"skip-auth-regex" cfg:"skip_auth_regex"`
57− SkipAuthRoutes []string `flag:"skip-auth-route" cfg:"skip_auth_routes"`
58− SkipJwtBearerTokens bool `flag:"skip-jwt-bearer-tokens" cfg:"skip_jwt_bearer_tokens"`
59− ExtraJwtIssuers []string `flag:"extra-jwt-issuers" cfg:"extra_jwt_issuers"`
60− SkipProviderButton bool `flag:"skip-provider-button" cfg:"skip_provider_button"`
61− SSLInsecureSkipVerify bool `flag:"ssl-insecure-skip-verify" cfg:"ssl_insecure_skip_verify"`
62− SkipAuthPreflight bool `flag:"skip-auth-preflight" cfg:"skip_auth_preflight"`
63− ForceJSONErrors bool `flag:"force-json-errors" cfg:"force_json_errors"`
64− EncodeState bool `flag:"encode-state" cfg:"encode_state"`
65− AllowQuerySemicolons bool `flag:"allow-query-semicolons" cfg:"allow_query_semicolons"`
55+ APIRoutes []string `flag:"api-route" cfg:"api_routes"`
56+ SkipAuthRegex []string `flag:"skip-auth-regex" cfg:"skip_auth_regex"`
57+ SkipAuthRoutes []string `flag:"skip-auth-route" cfg:"skip_auth_routes"`
58+ SkipJwtBearerTokens bool `flag:"skip-jwt-bearer-tokens" cfg:"skip_jwt_bearer_tokens"`
59+ BearerTokenLoginFallback bool `flag:"bearer-token-login-fallback" cfg:"bearer_token_login_fallback"`
60+ ExtraJwtIssuers []string `flag:"extra-jwt-issuers" cfg:"extra_jwt_issuers"`
61+ SkipProviderButton bool `flag:"skip-provider-button" cfg:"skip_provider_button"`
62+ SSLInsecureSkipVerify bool `flag:"ssl-insecure-skip-verify" cfg:"ssl_insecure_skip_verify"`
63+ SkipAuthPreflight bool `flag:"skip-auth-preflight" cfg:"skip_auth_preflight"`
64+ ForceJSONErrors bool `flag:"force-json-errors" cfg:"force_json_errors"`
65+ EncodeState bool `flag:"encode-state" cfg:"encode_state"`
66+ AllowQuerySemicolons bool `flag:"allow-query-semicolons" cfg:"allow_query_semicolons"`
6667
6768 SignatureKey string `flag:"signature-key" cfg:"signature_key"`
6869 GCPHealthChecks bool `flag:"gcp-healthchecks" cfg:"gcp_healthchecks"`
@@ -97,17 +98,18 @@ func (o *Options) SetRealClientIPParser(s ipapi.RealClientIPParser) { o.re
9798// NewOptions constructs a new Options with defaulted values
9899func NewOptions() *Options {
99100 return &Options{
100− ProxyPrefix: "/oauth2",
101− Providers: providerDefaults(),
102− PingPath: "/ping",
103− ReadyPath: "/ready",
104− RealClientIPHeader: "X-Real-IP",
105− ForceHTTPS: false,
106− Cookie: cookieDefaults(),
107− Session: sessionOptionsDefaults(),
108− Templates: templatesDefaults(),
109− SkipAuthPreflight: false,
110− Logging: loggingDefaults(),
101+ BearerTokenLoginFallback: true,
102+ ProxyPrefix: "/oauth2",
103+ Providers: providerDefaults(),
104+ PingPath: "/ping",
105+ ReadyPath: "/ready",
106+ RealClientIPHeader: "X-Real-IP",
107+ ForceHTTPS: false,
108+ Cookie: cookieDefaults(),
109+ Session: sessionOptionsDefaults(),
110+ Templates: templatesDefaults(),
111+ SkipAuthPreflight: false,
112+ Logging: loggingDefaults(),
111113 }
112114}
113115
@@ -128,6 +130,7 @@ func NewFlagSet() *pflag.FlagSet {
128130 flagSet.Bool("skip-auth-preflight", false, "will skip authentication for OPTIONS requests")
129131 flagSet.Bool("ssl-insecure-skip-verify", false, "skip validation of certificates presented when using HTTPS providers")
130132 flagSet.Bool("skip-jwt-bearer-tokens", false, "will skip requests that have verified JWT bearer tokens (default false)")
133+ 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")
131134 flagSet.Bool("force-json-errors", false, "will force JSON errors instead of HTTP error pages or redirects")
132135 flagSet.Bool("encode-state", false, "will encode oauth state with base64")
133136 flagSet.Bool("allow-query-semicolons", false, "allow the use of semicolons in query args")
pkg/middleware/jwt_session.go+13−6
@@ -15,10 +15,11 @@ import (
1515
1616const jwtRegexFormat = `^ey[a-zA-Z0-9_-]*\.ey[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]+$`
1717
18−func NewJwtSessionLoader(sessionLoaders []middlewareapi.TokenToSessionFunc) alice.Constructor {
18+func NewJwtSessionLoader(sessionLoaders []middlewareapi.TokenToSessionFunc, bearerTokenLoginFallback bool) alice.Constructor {
1919 js := &jwtSessionLoader{
20− jwtRegex: regexp.MustCompile(jwtRegexFormat),
21− sessionLoaders: sessionLoaders,
20+ jwtRegex: regexp.MustCompile(jwtRegexFormat),
21+ sessionLoaders: sessionLoaders,
22+ denyInvalidJWTs: !bearerTokenLoginFallback,
2223 }
2324 return js.loadSession
2425}
@@ -26,14 +27,16 @@ func NewJwtSessionLoader(sessionLoaders []middlewareapi.TokenToSessionFunc) alic
2627// jwtSessionLoader is responsible for loading sessions from JWTs in
2728// Authorization headers.
2829type jwtSessionLoader struct {
29− jwtRegex *regexp.Regexp
30− sessionLoaders []middlewareapi.TokenToSessionFunc
30+ jwtRegex *regexp.Regexp
31+ sessionLoaders []middlewareapi.TokenToSessionFunc
32+ denyInvalidJWTs bool
3133}
3234
3335// loadSession attempts to load a session from a JWT stored in an Authorization
3436// header within the request.
3537// If no authorization header is found, or the header is invalid, no session
3638// will be loaded and the request will be passed to the next handler.
39+// Or if the JWT is invalid and denyInvalidJWTs, return 403 now.
3740// If a session was loaded by a previous handler, it will not be replaced.
3841func (j *jwtSessionLoader) loadSession(next http.Handler) http.Handler {
3942 return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
@@ -49,6 +52,10 @@ func (j *jwtSessionLoader) loadSession(next http.Handler) http.Handler {
4952 session, err := j.getJwtSession(req)
5053 if err != nil {
5154 logger.Errorf("Error retrieving session from token in Authorization header: %v", err)
55+ if j.denyInvalidJWTs {
56+ http.Error(rw, http.StatusText(http.StatusForbidden), http.StatusForbidden)
57+ return
58+ }
5259 }
5360
5461 // Add the session to the scope if it was found
@@ -58,7 +65,7 @@ func (j *jwtSessionLoader) loadSession(next http.Handler) http.Handler {
5865}
5966
6067// getJwtSession loads a session based on a JWT token in the authorization header.
61−// (see the config options skip-jwt-bearer-tokens and extra-jwt-issuers)
68+// (see the config options skip-jwt-bearer-tokens, extra-jwt-issuers, and bearer-token-login-fallback)
6269func (j *jwtSessionLoader) getJwtSession(req *http.Request) (*sessionsapi.SessionState, error) {
6370 auth := req.Header.Get("Authorization")
6471 if auth == "" {
pkg/middleware/jwt_session_test.go+80−1
@@ -92,6 +92,7 @@ Nnc3a3lGVWFCNUMxQnNJcnJMTWxka1dFaHluYmI4Ongtb2F1dGgtYmFzaWM=`
9292 authorizationHeader string
9393 existingSession *sessionsapi.SessionState
9494 expectedSession *sessionsapi.SessionState
95+ expectedStatus int
9596 }
9697
9798 DescribeTable("with an authorization header",
@@ -114,12 +115,13 @@ Nnc3a3lGVWFCNUMxQnNJcnJMTWxka1dFaHluYmI4Ongtb2F1dGgtYmFzaWM=`
114115 // Create the handler with a next handler that will capture the session
115116 // from the scope
116117 var gotSession *sessionsapi.SessionState
117− handler := NewJwtSessionLoader(sessionLoaders)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+ handler := NewJwtSessionLoader(sessionLoaders, true)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118119 gotSession = middlewareapi.GetRequestScope(r).Session
119120 }))
120121 handler.ServeHTTP(rw, req)
121122
122123 Expect(gotSession).To(Equal(in.expectedSession))
124+ Expect(rw.Code).To(Equal(200))
123125 },
124126 Entry("<no value>", jwtSessionLoaderTableInput{
125127 authorizationHeader: "",
@@ -163,6 +165,83 @@ Nnc3a3lGVWFCNUMxQnNJcnJMTWxka1dFaHluYmI4Ongtb2F1dGgtYmFzaWM=`
163165 }),
164166 )
165167
168+ DescribeTable("with an authorization header, denyInvalidJWTs",
169+ func(in jwtSessionLoaderTableInput) {
170+ scope := &middlewareapi.RequestScope{
171+ Session: in.existingSession,
172+ }
173+
174+ // Set up the request with the authorization header and a request scope
175+ req := httptest.NewRequest("", "/", nil)
176+ req.Header.Set("Authorization", in.authorizationHeader)
177+ req = middlewareapi.AddRequestScope(req, scope)
178+
179+ rw := httptest.NewRecorder()
180+
181+ sessionLoaders := []middlewareapi.TokenToSessionFunc{
182+ middlewareapi.CreateTokenToSessionFunc(verifier),
183+ }
184+
185+ // Create the handler with a next handler that will capture the session
186+ // from the scope
187+ var gotSession *sessionsapi.SessionState
188+ handler := NewJwtSessionLoader(sessionLoaders, false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
189+ gotSession = middlewareapi.GetRequestScope(r).Session
190+ }))
191+ handler.ServeHTTP(rw, req)
192+
193+ Expect(gotSession).To(Equal(in.expectedSession))
194+ Expect(rw.Code).To(Equal(in.expectedStatus))
195+ },
196+ Entry("<no value>", jwtSessionLoaderTableInput{
197+ authorizationHeader: "",
198+ existingSession: nil,
199+ expectedSession: nil,
200+ expectedStatus: 200,
201+ }),
202+ Entry("abcdef", jwtSessionLoaderTableInput{
203+ authorizationHeader: "abcdef",
204+ existingSession: nil,
205+ expectedSession: nil,
206+ expectedStatus: 403,
207+ }),
208+ Entry("abcdef (with existing session)", jwtSessionLoaderTableInput{
209+ authorizationHeader: "abcdef",
210+ existingSession: &sessionsapi.SessionState{User: "user"},
211+ expectedSession: &sessionsapi.SessionState{User: "user"},
212+ expectedStatus: 200,
213+ }),
214+ Entry("Bearer <verifiedToken>", jwtSessionLoaderTableInput{
215+ authorizationHeader: fmt.Sprintf("Bearer %s", verifiedToken),
216+ existingSession: nil,
217+ expectedSession: verifiedSession,
218+ expectedStatus: 200,
219+ }),
220+ Entry("Bearer <nonVerifiedToken>", jwtSessionLoaderTableInput{
221+ authorizationHeader: fmt.Sprintf("Bearer %s", nonVerifiedToken),
222+ existingSession: nil,
223+ expectedSession: nil,
224+ expectedStatus: 403,
225+ }),
226+ Entry("Bearer <verifiedToken> (with existing session)", jwtSessionLoaderTableInput{
227+ authorizationHeader: fmt.Sprintf("Bearer %s", verifiedToken),
228+ existingSession: &sessionsapi.SessionState{User: "user"},
229+ expectedSession: &sessionsapi.SessionState{User: "user"},
230+ expectedStatus: 200,
231+ }),
232+ Entry("Basic Base64(<nonVerifiedToken>:) (No password)", jwtSessionLoaderTableInput{
233+ authorizationHeader: "Basic ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6",
234+ existingSession: nil,
235+ expectedSession: nil,
236+ expectedStatus: 403,
237+ }),
238+ Entry("Basic Base64(<verifiedToken>:x-oauth-basic) (Sentinel password)", jwtSessionLoaderTableInput{
239+ authorizationHeader: fmt.Sprintf("Basic %s", verifiedTokenXOAuthBasicBase64),
240+ existingSession: nil,
241+ expectedSession: verifiedSession,
242+ expectedStatus: 200,
243+ }),
244+ )
166245 })
167246
168247 Context("getJWTSession", func() {