diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index 60e117d..b9cdf9d 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -1,9 +1,11 @@ package controller import ( + "errors" "fmt" "net/http" - "slices" + "net/url" + "regexp" "strings" "github.com/steveiliop56/tinyauth/internal/config" @@ -15,12 +17,29 @@ import ( "github.com/google/go-querystring/query" ) -var SupportedProxies = []string{"nginx", "traefik", "caddy", "envoy"} +type AuthModuleType int + +const ( + AuthRequest AuthModuleType = iota + ExtAuthz + ForwardAuth +) + +var BrowserUserAgentRegex = regexp.MustCompile("Chrome|Gecko|AppleWebKit|Opera|Edge") type Proxy struct { Proxy string `uri:"proxy" binding:"required"` } +type ProxyContext struct { + Host string + Proto string + Path string + Method string + Type AuthModuleType + IsBrowser bool +} + type ProxyControllerConfig struct { AppURL string } @@ -43,82 +62,30 @@ func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, a func (controller *ProxyController) SetupRoutes() { proxyGroup := controller.router.Group("/auth") - // There is a later check to control allowed methods per proxy proxyGroup.Any("/:proxy", controller.proxyHandler) } func (controller *ProxyController) proxyHandler(c *gin.Context) { - var req Proxy + // Load proxy context based on the request type + proxyCtx, err := controller.getProxyContext(c) - err := c.BindUri(&req) if err != nil { - tlog.App.Error().Err(err).Msg("Failed to bind URI") + tlog.App.Warn().Err(err).Msg("Failed to get proxy context") c.JSON(400, gin.H{ "status": 400, - "message": "Bad Request", + "message": "Bad request", }) return } - if !slices.Contains(SupportedProxies, req.Proxy) { - tlog.App.Warn().Str("proxy", req.Proxy).Msg("Invalid proxy") - c.JSON(400, gin.H{ - "status": 400, - "message": "Bad Request", - }) - return - } - - // Only allow GET for non-envoy proxies. - // Envoy uses the original client method for the external auth request - // so we allow Any standard HTTP method for /api/auth/envoy - if req.Proxy != "envoy" && c.Request.Method != http.MethodGet { - tlog.App.Warn().Str("method", c.Request.Method).Msg("Invalid method for proxy") - c.Header("Allow", "GET") - c.JSON(405, gin.H{ - "status": 405, - "message": "Method Not Allowed", - }) - return - } - - isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html") - - if isBrowser { - tlog.App.Debug().Msg("Request identified as (most likely) coming from a browser") - } else { - tlog.App.Debug().Msg("Request identified as (most likely) coming from a non-browser client") - } - - // We are not marking the URI as a required header because it may be missing - // and we only use it for the auth enabled check which will simply not match - // if the header is missing. For deployments like Kubernetes, we use the - // x-original-uri header instead. - uri, ok := controller.getHeader(c, "x-forwarded-uri") - - if !ok { - originalUri, ok := controller.getHeader(c, "x-original-uri") - if ok { - uri = originalUri - } - } - - host, ok := controller.requireHeader(c, "x-forwarded-host") - if !ok { - return - } - - proto, ok := controller.requireHeader(c, "x-forwarded-proto") - if !ok { - return - } + tlog.App.Trace().Interface("ctx", proxyCtx).Msg("Got proxy context") // Get acls - acls, err := controller.acls.GetAccessControls(host) + acls, err := controller.acls.GetAccessControls(proxyCtx.Host) if err != nil { tlog.App.Error().Err(err).Msg("Failed to get access controls for resource") - controller.handleError(c, req, isBrowser) + controller.handleError(c, proxyCtx) return } @@ -135,11 +102,11 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { return } - authEnabled, err := controller.auth.IsAuthEnabled(uri, acls.Path) + authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls.Path) if err != nil { tlog.App.Error().Err(err).Msg("Failed to check if auth is enabled for resource") - controller.handleError(c, req, isBrowser) + controller.handleError(c, proxyCtx) return } @@ -154,7 +121,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { } if !controller.auth.CheckIP(acls.IP, clientIP) { - if req.Proxy == "nginx" || !isBrowser { + if !controller.useFriendlyError(proxyCtx) { c.JSON(401, gin.H{ "status": 401, "message": "Unauthorized", @@ -163,7 +130,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { } queries, err := query.Values(config.UnauthorizedQuery{ - Resource: strings.Split(host, ".")[0], + Resource: strings.Split(proxyCtx.Host, ".")[0], IP: clientIP, }) @@ -196,9 +163,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { userAllowed := controller.auth.IsUserAllowed(c, userContext, acls) if !userAllowed { - tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User not allowed to access resource") + tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User not allowed to access resource") - if req.Proxy == "nginx" || !isBrowser { + if !controller.useFriendlyError(proxyCtx) { c.JSON(403, gin.H{ "status": 403, "message": "Forbidden", @@ -207,7 +174,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { } queries, err := query.Values(config.UnauthorizedQuery{ - Resource: strings.Split(host, ".")[0], + Resource: strings.Split(proxyCtx.Host, ".")[0], }) if err != nil { @@ -236,9 +203,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { } if !groupOK { - tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User groups do not match resource requirements") + tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User groups do not match resource requirements") - if req.Proxy == "nginx" || !isBrowser { + if !controller.useFriendlyError(proxyCtx) { c.JSON(403, gin.H{ "status": 403, "message": "Forbidden", @@ -247,7 +214,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { } queries, err := query.Values(config.UnauthorizedQuery{ - Resource: strings.Split(host, ".")[0], + Resource: strings.Split(proxyCtx.Host, ".")[0], GroupErr: true, }) @@ -289,7 +256,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { return } - if req.Proxy == "nginx" || !isBrowser { + if !controller.useFriendlyError(proxyCtx) { c.JSON(401, gin.H{ "status": 401, "message": "Unauthorized", @@ -298,7 +265,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { } queries, err := query.Values(config.RedirectQuery{ - RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri), + RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path), }) if err != nil { @@ -328,8 +295,8 @@ func (controller *ProxyController) setHeaders(c *gin.Context, acls config.App) { } } -func (controller *ProxyController) handleError(c *gin.Context, req Proxy, isBrowser bool) { - if req.Proxy == "nginx" || !isBrowser { +func (controller *ProxyController) handleError(c *gin.Context, proxyCtx ProxyContext) { + if !controller.useFriendlyError(proxyCtx) { c.JSON(500, gin.H{ "status": 500, "message": "Internal Server Error", @@ -340,20 +307,195 @@ func (controller *ProxyController) handleError(c *gin.Context, req Proxy, isBrow c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) } -func (controller *ProxyController) requireHeader(c *gin.Context, header string) (string, bool) { - val, ok := controller.getHeader(c, header) - if !ok { - tlog.App.Error().Str("header", header).Msg("Header not found") - c.JSON(400, gin.H{ - "status": 400, - "message": "Bad Request", - }) - return "", false - } - return val, true -} - func (controller *ProxyController) getHeader(c *gin.Context, header string) (string, bool) { val := c.Request.Header.Get(header) return val, strings.TrimSpace(val) != "" } + +func (controller *ProxyController) useFriendlyError(proxyCtx ProxyContext) bool { + return (proxyCtx.Type == ForwardAuth || proxyCtx.Type == ExtAuthz) && proxyCtx.IsBrowser +} + +// Code below is inspired from https://github.com/authelia/authelia/blob/master/internal/handlers/handler_authz.go +// and thus it may be subject to Apache 2.0 License +func (controller *ProxyController) getForwardAuthContext(c *gin.Context) (ProxyContext, error) { + host, ok := controller.getHeader(c, "x-forwarded-host") + + if !ok { + return ProxyContext{}, errors.New("x-forwarded-host not found") + } + + uri, ok := controller.getHeader(c, "x-forwarded-uri") + + if !ok { + return ProxyContext{}, errors.New("x-forwarded-uri not found") + } + + proto, ok := controller.getHeader(c, "x-forwarded-proto") + + if !ok { + return ProxyContext{}, errors.New("x-forwarded-proto not found") + } + + // Normally we should only allow GET for forward auth but since it's a fallback + // for envoy we should allow everything, not a big deal + method := c.Request.Method + + return ProxyContext{ + Host: host, + Proto: proto, + Path: uri, + Method: method, + Type: ForwardAuth, + }, nil +} + +func (controller *ProxyController) getAuthRequestContext(c *gin.Context) (ProxyContext, error) { + xOriginalUrl, ok := controller.getHeader(c, "x-original-url") + + if !ok { + return ProxyContext{}, errors.New("x-original-url not found") + } + + url, err := url.Parse(xOriginalUrl) + + if err != nil { + return ProxyContext{}, err + } + + host := url.Host + + if strings.TrimSpace(host) == "" { + return ProxyContext{}, errors.New("host not found") + } + + proto := url.Scheme + + if strings.TrimSpace(proto) == "" { + return ProxyContext{}, errors.New("proto not found") + } + + path := url.Path + method := c.Request.Method + + return ProxyContext{ + Host: host, + Proto: proto, + Path: path, + Method: method, + Type: AuthRequest, + }, nil +} + +func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyContext, error) { + // We hope for the someone to set the x-forwarded-proto header + proto, ok := controller.getHeader(c, "x-forwarded-proto") + + if !ok { + return ProxyContext{}, errors.New("x-forwarded-proto not found") + } + + // It sets the host to the original host, not the forwarded host + host := c.Request.Host + + if strings.TrimSpace(host) == "" { + return ProxyContext{}, errors.New("host not found") + } + + // We get the path from the query string + path := c.Query("path") + + // For envoy we need to support every method + method := c.Request.Method + + return ProxyContext{ + Host: host, + Proto: proto, + Path: path, + Method: method, + Type: ExtAuthz, + }, nil +} + +func (controller *ProxyController) determineAuthModules(proxy string) []AuthModuleType { + switch proxy { + case "traefik", "caddy": + return []AuthModuleType{ForwardAuth} + case "envoy": + return []AuthModuleType{ExtAuthz, ForwardAuth} + case "nginx": + return []AuthModuleType{AuthRequest, ForwardAuth} + default: + return []AuthModuleType{} + } +} + +func (controller *ProxyController) getContextFromAuthModule(c *gin.Context, module AuthModuleType) (ProxyContext, error) { + switch module { + case ForwardAuth: + ctx, err := controller.getForwardAuthContext(c) + if err != nil { + return ProxyContext{}, err + } + return ctx, nil + case ExtAuthz: + ctx, err := controller.getExtAuthzContext(c) + if err != nil { + return ProxyContext{}, err + } + return ctx, nil + case AuthRequest: + ctx, err := controller.getAuthRequestContext(c) + if err != nil { + return ProxyContext{}, err + } + return ctx, nil + } + return ProxyContext{}, fmt.Errorf("unsupported auth module: %v", module) +} + +func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext, error) { + var req Proxy + + err := c.BindUri(&req) + if err != nil { + return ProxyContext{}, err + } + + tlog.App.Debug().Msgf("Proxy: %v", req.Proxy) + + authModules := controller.determineAuthModules(req.Proxy) + + if len(authModules) == 0 { + return ProxyContext{}, fmt.Errorf("no auth modules supported for proxy: %v", req.Proxy) + } + + var ctx ProxyContext + + for _, module := range authModules { + tlog.App.Debug().Msgf("Trying auth module: %v", module) + ctx, err = controller.getContextFromAuthModule(c, module) + if err == nil { + tlog.App.Debug().Msgf("Auth module %v succeeded", module) + break + } + tlog.App.Debug().Err(err).Msgf("Auth module %v failed", module) + } + + if err != nil { + return ProxyContext{}, err + } + + // We don't care if the header is empty, we will just assume it's not a browser + userAgent, _ := controller.getHeader(c, "user-agent") + isBrowser := BrowserUserAgentRegex.MatchString(userAgent) + + if isBrowser { + tlog.App.Debug().Msg("Request identified as coming from a browser") + } else { + tlog.App.Debug().Msg("Request identified as coming from a non-browser client") + } + + ctx.IsBrowser = isBrowser + return ctx, nil +} diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go index 1d91784..e22e7c4 100644 --- a/internal/controller/proxy_controller_test.go +++ b/internal/controller/proxy_controller_test.go @@ -1,6 +1,7 @@ package controller_test import ( + "net/http" "net/http/httptest" "testing" @@ -9,21 +10,26 @@ import ( "github.com/steveiliop56/tinyauth/internal/controller" "github.com/steveiliop56/tinyauth/internal/repository" "github.com/steveiliop56/tinyauth/internal/service" - "github.com/steveiliop56/tinyauth/internal/utils/tlog" "github.com/gin-gonic/gin" "gotest.tools/v3/assert" ) -func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder, *service.AuthService) { - tlog.NewSimpleLogger().Init() +var loggedInCtx = config.UserContext{ + Username: "test", + Name: "Test", + Email: "test@example.com", + IsLoggedIn: true, + Provider: "local", +} +func setupProxyController(t *testing.T, middlewares []gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) { // Setup gin.SetMode(gin.TestMode) router := gin.Default() - if middlewares != nil { - for _, m := range *middlewares { + if len(middlewares) > 0 { + for _, m := range middlewares { router.Use(m) } } @@ -48,7 +54,13 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En assert.NilError(t, dockerService.Init()) // Access controls - accessControlsService := service.NewAccessControlsService(dockerService, map[string]config.App{}) + accessControlsService := service.NewAccessControlsService(dockerService, map[string]config.App{ + "whoami": { + Path: config.AppPath{ + Allow: "/allow", + }, + }, + }) assert.NilError(t, accessControlsService.Init()) @@ -77,107 +89,214 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En // Controller ctrl := controller.NewProxyController(controller.ProxyControllerConfig{ - AppURL: "http://localhost:8080", + AppURL: "http://tinyauth.example.com", }, group, accessControlsService, authService) ctrl.SetupRoutes() - return router, recorder, authService + return router, recorder } // TODO: Needs tests for context middleware func TestProxyHandler(t *testing.T) { - // Setup - router, recorder, _ := setupProxyController(t, nil) + // Test logged out user traefik/caddy (forward_auth) + router, recorder := setupProxyController(t, nil) + + req, err := http.NewRequest("GET", "/api/auth/traefik", nil) + assert.NilError(t, err) + + req.Header.Set("x-forwarded-host", "whoami.example.com") + req.Header.Set("x-forwarded-proto", "http") + req.Header.Set("x-forwarded-uri", "/") - // Test invalid proxy - req := httptest.NewRequest("GET", "/api/auth/invalidproxy", nil) router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusUnauthorized) - assert.Equal(t, 400, recorder.Code) + // Test logged out user nginx (auth_request) + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/nginx", nil) + assert.NilError(t, err) + + req.Header.Set("x-original-url", "http://whoami.example.com/") - // Test invalid method for non-envoy proxy - recorder = httptest.NewRecorder() - req = httptest.NewRequest("POST", "/api/auth/traefik", nil) router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusUnauthorized) - assert.Equal(t, 405, recorder.Code) - assert.Equal(t, "GET", recorder.Header().Get("Allow")) + // Test logged out user envoy (ext_authz) + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/envoy?path=/", nil) + assert.NilError(t, err) + + req.Host = "whoami.example.com" + req.Header.Set("x-forwarded-proto", "http") - // Test logged out user (traefik/caddy) - recorder = httptest.NewRecorder() - req = httptest.NewRequest("GET", "/api/auth/traefik", nil) - req.Header.Set("X-Forwarded-Proto", "https") - req.Header.Set("X-Forwarded-Host", "example.com") - req.Header.Set("X-Forwarded-Uri", "/somepath") - req.Header.Set("Accept", "text/html") router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusUnauthorized) - assert.Equal(t, 307, recorder.Code) - assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location")) - - // Test logged out user (envoy - POST method) - recorder = httptest.NewRecorder() - req = httptest.NewRequest("POST", "/api/auth/envoy", nil) - req.Header.Set("X-Forwarded-Proto", "https") - req.Header.Set("X-Forwarded-Host", "example.com") - req.Header.Set("X-Forwarded-Uri", "/somepath") - req.Header.Set("Accept", "text/html") - router.ServeHTTP(recorder, req) - - assert.Equal(t, 307, recorder.Code) - assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location")) - - // Test logged out user (envoy - DELETE method) - recorder = httptest.NewRecorder() - req = httptest.NewRequest("DELETE", "/api/auth/envoy", nil) - req.Header.Set("X-Forwarded-Proto", "https") - req.Header.Set("X-Forwarded-Host", "example.com") - req.Header.Set("X-Forwarded-Uri", "/somepath") - req.Header.Set("Accept", "text/html") - router.ServeHTTP(recorder, req) - - assert.Equal(t, 307, recorder.Code) - assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location")) - - // Test logged out user (nginx) - recorder = httptest.NewRecorder() - req = httptest.NewRequest("GET", "/api/auth/nginx", nil) - req.Header.Set("X-Forwarded-Proto", "https") - req.Header.Set("X-Forwarded-Host", "example.com") - // we won't set X-Forwarded-Uri to test that the controller can work without it - router.ServeHTTP(recorder, req) - - assert.Equal(t, 401, recorder.Code) - - // Test logged in user - router, recorder, _ = setupProxyController(t, &[]gin.HandlerFunc{ + // Test logged in user traefik/caddy (forward_auth) + router, recorder = setupProxyController(t, []gin.HandlerFunc{ func(c *gin.Context) { - c.Set("context", &config.UserContext{ - Username: "testuser", - Name: "testuser", - Email: "testuser@example.com", - IsLoggedIn: true, - OAuth: false, - Provider: "local", - TotpPending: false, - OAuthGroups: "", - TotpEnabled: false, - }) + c.Set("context", &loggedInCtx) c.Next() }, }) - req = httptest.NewRequest("GET", "/api/auth/traefik", nil) - req.Header.Set("X-Forwarded-Proto", "https") - req.Header.Set("X-Forwarded-Host", "example.com") - req.Header.Set("X-Original-Uri", "/somepath") // Test with original URI for kubernetes ingress - req.Header.Set("Accept", "text/html") + req, err = http.NewRequest("GET", "/api/auth/traefik", nil) + assert.NilError(t, err) + + req.Header.Set("x-forwarded-host", "whoami.example.com") + req.Header.Set("x-forwarded-proto", "http") + req.Header.Set("x-forwarded-uri", "/") router.ServeHTTP(recorder, req) - assert.Equal(t, 200, recorder.Code) + assert.Equal(t, recorder.Code, http.StatusOK) - assert.Equal(t, "testuser", recorder.Header().Get("Remote-User")) - assert.Equal(t, "testuser", recorder.Header().Get("Remote-Name")) - assert.Equal(t, "testuser@example.com", recorder.Header().Get("Remote-Email")) + // Test logged in user nginx (auth_request) + router, recorder = setupProxyController(t, []gin.HandlerFunc{ + func(c *gin.Context) { + c.Set("context", &loggedInCtx) + c.Next() + }, + }) + + req, err = http.NewRequest("GET", "/api/auth/nginx", nil) + assert.NilError(t, err) + + req.Header.Set("x-original-url", "http://whoami.example.com/") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) + + // Test logged in user envoy (ext_authz) + router, recorder = setupProxyController(t, []gin.HandlerFunc{ + func(c *gin.Context) { + c.Set("context", &loggedInCtx) + c.Next() + }, + }) + + req, err = http.NewRequest("GET", "/api/auth/envoy?path=/", nil) + assert.NilError(t, err) + + req.Host = "whoami.example.com" + req.Header.Set("x-forwarded-proto", "http") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) + + // Test ACL allow caddy/traefik (forward_auth) + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/traefik", nil) + assert.NilError(t, err) + + req.Header.Set("x-forwarded-host", "whoami.example.com") + req.Header.Set("x-forwarded-proto", "http") + req.Header.Set("x-forwarded-uri", "/allow") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) + + // Test ACL allow nginx + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/nginx", nil) + assert.NilError(t, err) + + req.Header.Set("x-original-url", "http://whoami.example.com/allow") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) + + // Test ACL allow envoy + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/envoy?path=/allow", nil) + assert.NilError(t, err) + + req.Host = "whoami.example.com" + req.Header.Set("x-forwarded-proto", "http") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) + + // Test traefik/caddy (forward_auth) without required headers + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/traefik", nil) + assert.NilError(t, err) + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusBadRequest) + + // Test nginx (forward_auth) without required headers + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/nginx", nil) + assert.NilError(t, err) + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusBadRequest) + + // Test envoy (forward_auth) without required headers + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/envoy", nil) + assert.NilError(t, err) + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusBadRequest) + + // Test nginx (auth_request) with forward_auth fallback with ACLs + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/nginx", nil) + assert.NilError(t, err) + + req.Header.Set("x-forwarded-host", "whoami.example.com") + req.Header.Set("x-forwarded-proto", "http") + req.Header.Set("x-forwarded-uri", "/allow") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) + + // Test envoy (ext_authz) with forward_auth fallback with ACLs + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/envoy", nil) + assert.NilError(t, err) + + req.Header.Set("x-forwarded-host", "whoami.example.com") + req.Header.Set("x-forwarded-proto", "http") + req.Header.Set("x-forwarded-uri", "/allow") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) + + // Test envoy (ext_authz) with empty path + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/envoy", nil) + assert.NilError(t, err) + + req.Host = "whoami.example.com" + req.Header.Set("x-forwarded-proto", "http") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusUnauthorized) + + // Ensure forward_auth fallback works with path (should ignore) + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/traefik?path=/allow", nil) + assert.NilError(t, err) + + req.Header.Set("x-forwarded-proto", "http") + req.Header.Set("x-forwarded-host", "whoami.example.com") + req.Header.Set("x-forwarded-uri", "/allow") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) }