From 81cb695d0bf2666f219f60f9beb48812556e42e1 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 13 Mar 2026 17:45:11 +0200 Subject: [PATCH 1/6] wip --- internal/controller/proxy_controller.go | 287 +++++++++++++++++------- 1 file changed, 201 insertions(+), 86 deletions(-) diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index 60e117d4..77384297 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,31 @@ import ( "github.com/google/go-querystring/query" ) +type RequestType int + +const ( + AuthRequest RequestType = iota + ExtAuthz + ForwardAuth +) + +var BrowserUserAgentRegex = regexp.MustCompile("Chrome|Gecko|AppleWebKit|Opera|Edge") + var SupportedProxies = []string{"nginx", "traefik", "caddy", "envoy"} type Proxy struct { Proxy string `uri:"proxy" binding:"required"` } +type ProxyContext struct { + Host string + Proto string + Path string + Method string + Type RequestType + IsBrowser bool +} + type ProxyControllerConfig struct { AppURL string } @@ -43,82 +64,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") - c.JSON(400, gin.H{ - "status": 400, - "message": "Bad Request", - }) - return - } - - if !slices.Contains(SupportedProxies, req.Proxy) { - tlog.App.Warn().Str("proxy", req.Proxy).Msg("Invalid proxy") + 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 } - // 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 +104,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 +123,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 +132,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 +165,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 +176,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 +205,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 +216,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 +258,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 +267,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 +297,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 +309,166 @@ 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) +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.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 { - tlog.App.Error().Str("header", header).Msg("Header not found") - c.JSON(400, gin.H{ - "status": 400, - "message": "Bad Request", - }) - return "", false + 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") } - return val, true + + method := c.Request.Method + + if method != http.MethodGet { + return ProxyContext{}, errors.New("method not allowed") + } + + return ProxyContext{ + Host: host, + Proto: proto, + Path: uri, + Method: method, + Type: ForwardAuth, + }, nil } -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) 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 + proto := url.Scheme + path := url.Path + method := c.Request.Method + + if method != http.MethodGet { + return ProxyContext{}, errors.New("method not allowed") + } + + return ProxyContext{ + Host: host, + Proto: proto, + Path: path, + Method: method, + Type: AuthRequest, + }, nil +} + +func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyContext, error) { + proto, ok := controller.getHeader(c, "x-forwarded-proto") + + if !ok { + return ProxyContext{}, errors.New("x-forwarded-proto not found") + } + + host, ok := controller.getHeader(c, "host") + + if !ok { + return ProxyContext{}, errors.New("host not found") + } + + // Seems like we can't get the path? + + // For envoy we need to support every method + method := c.Request.Method + + return ProxyContext{ + Host: host, + Proto: proto, + Method: method, + Type: ExtAuthz, + }, nil +} + +func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext, error) { + var req Proxy + + err := c.BindUri(&req) + if err != nil { + return ProxyContext{}, err + } + + var ctx ProxyContext + + switch req.Proxy { + // For nginx we need to handle both forward_auth and auth_request extraction since it can be + // used either with something line nginx proxy manager with advanced config or with + // the kubernetes ingress controller + case "nginx": + tlog.App.Debug().Str("proxy", req.Proxy).Msg("Attempting forward_auth compatible extraction") + forwardAuthCtx, err := controller.getForwardAuthContext(c) + if err == nil { + tlog.App.Debug().Str("proxy", req.Proxy).Msg("Extractions success using forward_auth") + ctx = forwardAuthCtx + } else { + tlog.App.Debug().Str("proxy", req.Proxy).Msg("Extractions failed using forward_auth trying with auth_request") + authRequestCtx, err := controller.getAuthRequestContext(c) + if err != nil { + tlog.App.Warn().Str("proxy", req.Proxy).Msg("Failed to determine required module for header extraction") + return ProxyContext{}, err + } + ctx = authRequestCtx + } + case "envoy": + tlog.App.Debug().Str("proxy", req.Proxy).Msg("Attempting ext_authz compatible extraction") + extAuthzCtx, err := controller.getExtAuthzContext(c) + if err != nil { + tlog.App.Warn().Str("proxy", req.Proxy).Msg("Failed to determine required module for header extraction") + return ProxyContext{}, err + } + ctx = extAuthzCtx + // By default we fallback to the forward_auth module which supports most proxies like traefik or caddy + default: + tlog.App.Debug().Str("proxy", req.Proxy).Msg("Attempting forward_auth compatible extraction") + forwardAuthCtx, err := controller.getForwardAuthContext(c) + if err != nil { + tlog.App.Warn().Str("proxy", req.Proxy).Msg("Failed to determine required module for header extraction") + return ProxyContext{}, err + } + ctx = forwardAuthCtx + } + + // 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 (most likely) coming from a browser") + } else { + tlog.App.Debug().Msg("Request identified as (most likely) coming from a non-browser client") + } + + ctx.IsBrowser = isBrowser + return ctx, nil } From 13bb3ca68207854d2a740c76d7ef2158c110381f Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 13 Mar 2026 23:03:11 +0200 Subject: [PATCH 2/6] fix: add extauthz to friendly error messages --- internal/controller/proxy_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index 77384297..ac994734 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -315,7 +315,7 @@ func (controller *ProxyController) getHeader(c *gin.Context, header string) (str } func (controller *ProxyController) useFriendlyError(proxyCtx ProxyContext) bool { - return proxyCtx.Type == ForwardAuth && proxyCtx.IsBrowser + 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 From fdcb25307204c297ceb9ee038353ff606272407e Mon Sep 17 00:00:00 2001 From: Stavros Date: Sat, 14 Mar 2026 12:09:38 +0200 Subject: [PATCH 3/6] refactor: better module handling per proxy --- internal/controller/proxy_controller.go | 109 +++++++++++++----------- 1 file changed, 61 insertions(+), 48 deletions(-) diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index ac994734..9f5bbe40 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -17,10 +17,10 @@ import ( "github.com/google/go-querystring/query" ) -type RequestType int +type AuthModuleType int const ( - AuthRequest RequestType = iota + AuthRequest AuthModuleType = iota ExtAuthz ForwardAuth ) @@ -38,7 +38,7 @@ type ProxyContext struct { Proto string Path string Method string - Type RequestType + Type AuthModuleType IsBrowser bool } @@ -341,10 +341,6 @@ func (controller *ProxyController) getForwardAuthContext(c *gin.Context) (ProxyC method := c.Request.Method - if method != http.MethodGet { - return ProxyContext{}, errors.New("method not allowed") - } - return ProxyContext{ Host: host, Proto: proto, @@ -372,10 +368,6 @@ func (controller *ProxyController) getAuthRequestContext(c *gin.Context) (ProxyC path := url.Path method := c.Request.Method - if method != http.MethodGet { - return ProxyContext{}, errors.New("method not allowed") - } - return ProxyContext{ Host: host, Proto: proto, @@ -398,7 +390,8 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont return ProxyContext{}, errors.New("host not found") } - // Seems like we can't get the path? + // We get the path from the query string + path := c.Query("path") // For envoy we need to support every method method := c.Request.Method @@ -406,11 +399,49 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont return ProxyContext{ Host: host, Proto: proto, + Path: path, Method: method, Type: ExtAuthz, }, nil } +func (controller *ProxyController) determineAuthModules(proxy string) []AuthModuleType { + switch proxy { + case "traefik": + return []AuthModuleType{ForwardAuth} + case "envoy": + return []AuthModuleType{ExtAuthz, ForwardAuth} + case "nginx": + return []AuthModuleType{AuthRequest, ForwardAuth} + default: + return []AuthModuleType{ForwardAuth} + } +} + +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 @@ -419,44 +450,26 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext return ProxyContext{}, err } + tlog.App.Debug().Msgf("Proxy: %v", req.Proxy) + + tlog.App.Trace().Interface("headers", c.Request.Header).Msg("Request headers") + + authModules := controller.determineAuthModules(req.Proxy) + var ctx ProxyContext - switch req.Proxy { - // For nginx we need to handle both forward_auth and auth_request extraction since it can be - // used either with something line nginx proxy manager with advanced config or with - // the kubernetes ingress controller - case "nginx": - tlog.App.Debug().Str("proxy", req.Proxy).Msg("Attempting forward_auth compatible extraction") - forwardAuthCtx, err := controller.getForwardAuthContext(c) + 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().Str("proxy", req.Proxy).Msg("Extractions success using forward_auth") - ctx = forwardAuthCtx - } else { - tlog.App.Debug().Str("proxy", req.Proxy).Msg("Extractions failed using forward_auth trying with auth_request") - authRequestCtx, err := controller.getAuthRequestContext(c) - if err != nil { - tlog.App.Warn().Str("proxy", req.Proxy).Msg("Failed to determine required module for header extraction") - return ProxyContext{}, err - } - ctx = authRequestCtx - } - case "envoy": - tlog.App.Debug().Str("proxy", req.Proxy).Msg("Attempting ext_authz compatible extraction") - extAuthzCtx, err := controller.getExtAuthzContext(c) - if err != nil { - tlog.App.Warn().Str("proxy", req.Proxy).Msg("Failed to determine required module for header extraction") - return ProxyContext{}, err - } - ctx = extAuthzCtx - // By default we fallback to the forward_auth module which supports most proxies like traefik or caddy - default: - tlog.App.Debug().Str("proxy", req.Proxy).Msg("Attempting forward_auth compatible extraction") - forwardAuthCtx, err := controller.getForwardAuthContext(c) - if err != nil { - tlog.App.Warn().Str("proxy", req.Proxy).Msg("Failed to determine required module for header extraction") - return ProxyContext{}, err + tlog.App.Debug().Msgf("Auth module %v succeeded", module) + break } - ctx = forwardAuthCtx + 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 @@ -464,9 +477,9 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext isBrowser := BrowserUserAgentRegex.MatchString(userAgent) if isBrowser { - tlog.App.Debug().Msg("Request identified as (most likely) coming from a browser") + tlog.App.Debug().Msg("Request identified as coming from a browser") } else { - tlog.App.Debug().Msg("Request identified as (most likely) coming from a non-browser client") + tlog.App.Debug().Msg("Request identified as coming from a non-browser client") } ctx.IsBrowser = isBrowser From 134befe72f843c52503ddd556f9db74dbff4d898 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sat, 14 Mar 2026 12:40:38 +0200 Subject: [PATCH 4/6] fix: get envoy host from the gin request --- internal/controller/proxy_controller.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index 9f5bbe40..7d650bea 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -339,6 +339,8 @@ func (controller *ProxyController) getForwardAuthContext(c *gin.Context) (ProxyC 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{ @@ -378,17 +380,15 @@ func (controller *ProxyController) getAuthRequestContext(c *gin.Context) (ProxyC } 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") } - host, ok := controller.getHeader(c, "host") - - if !ok { - return ProxyContext{}, errors.New("host not found") - } + // It sets the host to the original host, not the forwarded host + host := c.Request.URL.Host // We get the path from the query string path := c.Query("path") From 1c7ef9693d4bca286dfb08f91d09864450249ba3 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sat, 14 Mar 2026 13:27:14 +0200 Subject: [PATCH 5/6] tests: rework tests for proxy controller --- internal/controller/proxy_controller.go | 6 +- internal/controller/proxy_controller_test.go | 271 +++++++++++++------ 2 files changed, 200 insertions(+), 77 deletions(-) diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index 7d650bea..b215a5ca 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -388,7 +388,11 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont } // It sets the host to the original host, not the forwarded host - host := c.Request.URL.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") diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go index 1d917840..e22e7c4c 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) - // Test invalid proxy - req := httptest.NewRequest("GET", "/api/auth/invalidproxy", nil) - router.ServeHTTP(recorder, req) + req, err := http.NewRequest("GET", "/api/auth/traefik", nil) + assert.NilError(t, err) - assert.Equal(t, 400, recorder.Code) + req.Header.Set("x-forwarded-host", "whoami.example.com") + req.Header.Set("x-forwarded-proto", "http") + req.Header.Set("x-forwarded-uri", "/") - // 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 nginx (auth_request) + router, recorder = setupProxyController(t, nil) - // 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) + req, err = http.NewRequest("GET", "/api/auth/nginx", nil) + assert.NilError(t, err) - 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")) + req.Header.Set("x-original-url", "http://whoami.example.com/") - // 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, recorder.Code, http.StatusUnauthorized) + + // 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) - 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")) + req.Host = "whoami.example.com" + req.Header.Set("x-forwarded-proto", "http") - // 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, 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 in user traefik/caddy (forward_auth) + router, recorder = setupProxyController(t, []gin.HandlerFunc{ + func(c *gin.Context) { + c.Set("context", &loggedInCtx) + c.Next() + }, + }) + + 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 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, recorder.Code, http.StatusOK) - assert.Equal(t, 401, recorder.Code) + // 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 - router, recorder, _ = setupProxyController(t, &[]gin.HandlerFunc{ + // Test logged in user envoy (ext_authz) + 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/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, 200, recorder.Code) + assert.Equal(t, recorder.Code, http.StatusBadRequest) + + // Test envoy (forward_auth) without required headers + router, recorder = setupProxyController(t, nil) - 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")) + 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) } From b98fa341100eaeeec9b909d82b79ae7229471fd4 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sat, 14 Mar 2026 14:32:17 +0200 Subject: [PATCH 6/6] fix: review comments --- internal/controller/proxy_controller.go | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index b215a5ca..b9cdf9da 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -27,8 +27,6 @@ const ( var BrowserUserAgentRegex = regexp.MustCompile("Chrome|Gecko|AppleWebKit|Opera|Edge") -var SupportedProxies = []string{"nginx", "traefik", "caddy", "envoy"} - type Proxy struct { Proxy string `uri:"proxy" binding:"required"` } @@ -366,7 +364,17 @@ func (controller *ProxyController) getAuthRequestContext(c *gin.Context) (ProxyC } 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 @@ -411,14 +419,14 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont func (controller *ProxyController) determineAuthModules(proxy string) []AuthModuleType { switch proxy { - case "traefik": + case "traefik", "caddy": return []AuthModuleType{ForwardAuth} case "envoy": return []AuthModuleType{ExtAuthz, ForwardAuth} case "nginx": return []AuthModuleType{AuthRequest, ForwardAuth} default: - return []AuthModuleType{ForwardAuth} + return []AuthModuleType{} } } @@ -456,10 +464,12 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext tlog.App.Debug().Msgf("Proxy: %v", req.Proxy) - tlog.App.Trace().Interface("headers", c.Request.Header).Msg("Request headers") - 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 {