1
0
mirror of https://github.com/oauth2-proxy/oauth2-proxy.git synced 2025-05-19 22:23:30 +02:00

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>
This commit is contained in:
Justin Ryan 2025-04-21 08:40:39 -04:00 committed by GitHub
parent bb6ff4ed14
commit 8abdbb5a18
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 137 additions and 41 deletions

View File

@ -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)

View File

@ -191,8 +191,9 @@ Provider specific options can be found on their respective subpages.
| Flag / Config Field | Type | Description | Default |
| ------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| 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` |
| 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 | |
| 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 | |
| flag: `--authenticated-emails-file`<br/>toml: `authenticated_emails_file` | string | authenticate against emails via file (one per line) | |
| 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 |
| 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 | |
| flag: `--encode-state`<br/>toml: `encode_state` | bool | encode the state parameter as UrlEncodedBase64 | false |
| 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`) | |

View File

@ -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 {

View File

@ -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(),
},
}

View File

@ -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")

View File

@ -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 == "" {

View File

@ -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("<no value>", 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("<no value>", 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 <verifiedToken>", jwtSessionLoaderTableInput{
authorizationHeader: fmt.Sprintf("Bearer %s", verifiedToken),
existingSession: nil,
expectedSession: verifiedSession,
expectedStatus: 200,
}),
Entry("Bearer <nonVerifiedToken>", jwtSessionLoaderTableInput{
authorizationHeader: fmt.Sprintf("Bearer %s", nonVerifiedToken),
existingSession: nil,
expectedSession: nil,
expectedStatus: 403,
}),
Entry("Bearer <verifiedToken> (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(<nonVerifiedToken>:) (No password)", jwtSessionLoaderTableInput{
authorizationHeader: "Basic ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6",
existingSession: nil,
expectedSession: nil,
expectedStatus: 403,
}),
Entry("Basic Base64(<verifiedToken>:x-oauth-basic) (Sentinel password)", jwtSessionLoaderTableInput{
authorizationHeader: fmt.Sprintf("Basic %s", verifiedTokenXOAuthBasicBase64),
existingSession: nil,
expectedSession: verifiedSession,
expectedStatus: 200,
}),
)
})
Context("getJWTSession", func() {