Compare commits

...

4 Commits

Author SHA1 Message Date
Stavros
134befe72f fix: get envoy host from the gin request 2026-03-14 12:40:38 +02:00
Stavros
fdcb253072 refactor: better module handling per proxy 2026-03-14 12:09:38 +02:00
Stavros
13bb3ca682 fix: add extauthz to friendly error messages 2026-03-13 23:03:11 +02:00
Stavros
81cb695d0b wip 2026-03-13 17:45:11 +02:00

View File

@@ -1,9 +1,11 @@
package controller package controller
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"slices" "net/url"
"regexp"
"strings" "strings"
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
@@ -15,12 +17,31 @@ import (
"github.com/google/go-querystring/query" "github.com/google/go-querystring/query"
) )
type AuthModuleType int
const (
AuthRequest AuthModuleType = iota
ExtAuthz
ForwardAuth
)
var BrowserUserAgentRegex = regexp.MustCompile("Chrome|Gecko|AppleWebKit|Opera|Edge")
var SupportedProxies = []string{"nginx", "traefik", "caddy", "envoy"} var SupportedProxies = []string{"nginx", "traefik", "caddy", "envoy"}
type Proxy struct { type Proxy struct {
Proxy string `uri:"proxy" binding:"required"` Proxy string `uri:"proxy" binding:"required"`
} }
type ProxyContext struct {
Host string
Proto string
Path string
Method string
Type AuthModuleType
IsBrowser bool
}
type ProxyControllerConfig struct { type ProxyControllerConfig struct {
AppURL string AppURL string
} }
@@ -43,82 +64,30 @@ func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, a
func (controller *ProxyController) SetupRoutes() { func (controller *ProxyController) SetupRoutes() {
proxyGroup := controller.router.Group("/auth") proxyGroup := controller.router.Group("/auth")
// There is a later check to control allowed methods per proxy
proxyGroup.Any("/:proxy", controller.proxyHandler) proxyGroup.Any("/:proxy", controller.proxyHandler)
} }
func (controller *ProxyController) proxyHandler(c *gin.Context) { 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 { 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{ c.JSON(400, gin.H{
"status": 400, "status": 400,
"message": "Bad Request", "message": "Bad request",
}) })
return return
} }
if !slices.Contains(SupportedProxies, req.Proxy) { tlog.App.Trace().Interface("ctx", proxyCtx).Msg("Got proxy context")
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
}
// Get acls // Get acls
acls, err := controller.acls.GetAccessControls(host) acls, err := controller.acls.GetAccessControls(proxyCtx.Host)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to get access controls for resource") tlog.App.Error().Err(err).Msg("Failed to get access controls for resource")
controller.handleError(c, req, isBrowser) controller.handleError(c, proxyCtx)
return return
} }
@@ -135,11 +104,11 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
authEnabled, err := controller.auth.IsAuthEnabled(uri, acls.Path) authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls.Path)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to check if auth is enabled for resource") 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 return
} }
@@ -154,7 +123,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
} }
if !controller.auth.CheckIP(acls.IP, clientIP) { if !controller.auth.CheckIP(acls.IP, clientIP) {
if req.Proxy == "nginx" || !isBrowser { if !controller.useFriendlyError(proxyCtx) {
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -163,7 +132,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
} }
queries, err := query.Values(config.UnauthorizedQuery{ queries, err := query.Values(config.UnauthorizedQuery{
Resource: strings.Split(host, ".")[0], Resource: strings.Split(proxyCtx.Host, ".")[0],
IP: clientIP, IP: clientIP,
}) })
@@ -196,9 +165,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
userAllowed := controller.auth.IsUserAllowed(c, userContext, acls) userAllowed := controller.auth.IsUserAllowed(c, userContext, acls)
if !userAllowed { 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{ c.JSON(403, gin.H{
"status": 403, "status": 403,
"message": "Forbidden", "message": "Forbidden",
@@ -207,7 +176,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
} }
queries, err := query.Values(config.UnauthorizedQuery{ queries, err := query.Values(config.UnauthorizedQuery{
Resource: strings.Split(host, ".")[0], Resource: strings.Split(proxyCtx.Host, ".")[0],
}) })
if err != nil { if err != nil {
@@ -236,9 +205,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
} }
if !groupOK { 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{ c.JSON(403, gin.H{
"status": 403, "status": 403,
"message": "Forbidden", "message": "Forbidden",
@@ -247,7 +216,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
} }
queries, err := query.Values(config.UnauthorizedQuery{ queries, err := query.Values(config.UnauthorizedQuery{
Resource: strings.Split(host, ".")[0], Resource: strings.Split(proxyCtx.Host, ".")[0],
GroupErr: true, GroupErr: true,
}) })
@@ -289,7 +258,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
if req.Proxy == "nginx" || !isBrowser { if !controller.useFriendlyError(proxyCtx) {
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -298,7 +267,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
} }
queries, err := query.Values(config.RedirectQuery{ 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 { 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) { func (controller *ProxyController) handleError(c *gin.Context, proxyCtx ProxyContext) {
if req.Proxy == "nginx" || !isBrowser { if !controller.useFriendlyError(proxyCtx) {
c.JSON(500, gin.H{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",
@@ -340,20 +309,179 @@ func (controller *ProxyController) handleError(c *gin.Context, req Proxy, isBrow
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) 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) { func (controller *ProxyController) getHeader(c *gin.Context, header string) (string, bool) {
val := c.Request.Header.Get(header) val := c.Request.Header.Get(header)
return val, strings.TrimSpace(val) != "" 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
proto := url.Scheme
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.URL.Host
// 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":
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
err := c.BindUri(&req)
if err != nil {
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
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
}