mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-09-01 05:08:33 +00:00
fix: auth module selection (#1089)
This commit is contained in:
@@ -235,6 +235,8 @@ TINYAUTH_LDAP_GROUPCACHETTL=900
|
|||||||
|
|
||||||
# Enable the OAuth bridge, uses a new way to format OAuth user information.
|
# Enable the OAuth bridge, uses a new way to format OAuth user information.
|
||||||
TINYAUTH_EXPERIMENTAL_OAUTHBRIDGEENABLED=false
|
TINYAUTH_EXPERIMENTAL_OAUTHBRIDGEENABLED=false
|
||||||
|
# Disable the fallback to forward_auth modules when auth_request or ext_authz fail.
|
||||||
|
TINYAUTH_EXPERIMENTAL_DISABLEAUTHMODULEFALLBACK=false
|
||||||
|
|
||||||
# tailscale config
|
# tailscale config
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,8 @@ func (controller *OAuthController) getCookieDomain() string {
|
|||||||
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
|
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
|
||||||
v := validators.NewDomainValidator(validators.DomainValidatorOptions{
|
v := validators.NewDomainValidator(validators.DomainValidatorOptions{
|
||||||
WithPort: true,
|
WithPort: true,
|
||||||
|
WithScheme: true,
|
||||||
|
AllowedSchemes: []string{"https", "http"},
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err := v.SafeHostname(controller.runtime.AppURL)
|
_, err := v.SafeHostname(controller.runtime.AppURL)
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type ProxyContext struct {
|
|||||||
type ProxyController struct {
|
type ProxyController struct {
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
runtime *model.RuntimeConfig
|
runtime *model.RuntimeConfig
|
||||||
|
config *model.Config
|
||||||
acls *service.AccessControlsService
|
acls *service.AccessControlsService
|
||||||
auth *service.AuthService
|
auth *service.AuthService
|
||||||
policyEngine *service.PolicyEngine
|
policyEngine *service.PolicyEngine
|
||||||
@@ -67,6 +68,7 @@ type ProxyControllerInput struct {
|
|||||||
|
|
||||||
Log *logger.Logger
|
Log *logger.Logger
|
||||||
RuntimeConfig *model.RuntimeConfig
|
RuntimeConfig *model.RuntimeConfig
|
||||||
|
Config *model.Config
|
||||||
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"`
|
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"`
|
||||||
ACLsService *service.AccessControlsService
|
ACLsService *service.AccessControlsService
|
||||||
AuthService *service.AuthService
|
AuthService *service.AuthService
|
||||||
@@ -77,6 +79,7 @@ func NewProxyController(i ProxyControllerInput) *ProxyController {
|
|||||||
controller := &ProxyController{
|
controller := &ProxyController{
|
||||||
log: i.Log,
|
log: i.Log,
|
||||||
runtime: i.RuntimeConfig,
|
runtime: i.RuntimeConfig,
|
||||||
|
config: i.Config,
|
||||||
acls: i.ACLsService,
|
acls: i.ACLsService,
|
||||||
auth: i.AuthService,
|
auth: i.AuthService,
|
||||||
policyEngine: i.PolicyEngine,
|
policyEngine: i.PolicyEngine,
|
||||||
@@ -465,6 +468,10 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont
|
|||||||
// We get the path from the query string
|
// We get the path from the query string
|
||||||
path := c.Query("path")
|
path := c.Query("path")
|
||||||
|
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return ProxyContext{}, errors.New("path not found")
|
||||||
|
}
|
||||||
|
|
||||||
// For envoy we need to support every method
|
// For envoy we need to support every method
|
||||||
method := c.Request.Method
|
method := c.Request.Method
|
||||||
|
|
||||||
@@ -477,14 +484,22 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *ProxyController) determineAuthModules(proxy ProxyType) []AuthModuleType {
|
func (controller *ProxyController) determineAuthModules(proxy ProxyType, fallbacks bool) []AuthModuleType {
|
||||||
switch proxy {
|
switch proxy {
|
||||||
case Traefik, Caddy:
|
case Traefik, Caddy:
|
||||||
return []AuthModuleType{ForwardAuth}
|
return []AuthModuleType{ForwardAuth}
|
||||||
case Envoy:
|
case Envoy:
|
||||||
return []AuthModuleType{ExtAuthz, ForwardAuth}
|
authModules := []AuthModuleType{ExtAuthz}
|
||||||
|
if fallbacks {
|
||||||
|
authModules = append(authModules, ForwardAuth)
|
||||||
|
}
|
||||||
|
return authModules
|
||||||
case Nginx:
|
case Nginx:
|
||||||
return []AuthModuleType{AuthRequest, ForwardAuth}
|
authModules := []AuthModuleType{AuthRequest}
|
||||||
|
if fallbacks {
|
||||||
|
authModules = append(authModules, ForwardAuth)
|
||||||
|
}
|
||||||
|
return authModules
|
||||||
default:
|
default:
|
||||||
return []AuthModuleType{}
|
return []AuthModuleType{}
|
||||||
}
|
}
|
||||||
@@ -514,6 +529,39 @@ func (controller *ProxyController) getContextFromAuthModule(c *gin.Context, modu
|
|||||||
return ProxyContext{}, fmt.Errorf("unsupported auth module: %v", module)
|
return ProxyContext{}, fmt.Errorf("unsupported auth module: %v", module)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (controller *ProxyController) authModuleIdentifiersPresent(c *gin.Context, module AuthModuleType) bool {
|
||||||
|
switch module {
|
||||||
|
case ForwardAuth:
|
||||||
|
_, host := controller.getHeader(c, "x-forwarded-host")
|
||||||
|
_, uri := controller.getHeader(c, "x-forwarded-uri")
|
||||||
|
return host || uri
|
||||||
|
case AuthRequest:
|
||||||
|
_, ok := controller.getHeader(c, "x-original-url")
|
||||||
|
return ok
|
||||||
|
case ExtAuthz:
|
||||||
|
return strings.TrimSpace(c.Query("path")) != ""
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (controller *ProxyController) ensureNoMultipleAuthModules(c *gin.Context, authModules []AuthModuleType) error {
|
||||||
|
present := 0
|
||||||
|
|
||||||
|
for _, module := range authModules {
|
||||||
|
if controller.authModuleIdentifiersPresent(c, module) {
|
||||||
|
present++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if present > 1 {
|
||||||
|
controller.log.App.Warn().Msg("Request carries headers for multiple auth modules, possible spoofing attempt, denying")
|
||||||
|
return fmt.Errorf("conflicting auth module headers")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext, error) {
|
func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext, error) {
|
||||||
var req Proxy
|
var req Proxy
|
||||||
|
|
||||||
@@ -530,28 +578,36 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
|
|||||||
|
|
||||||
controller.log.App.Debug().Msgf("Determined proxy type: %v", proxy)
|
controller.log.App.Debug().Msgf("Determined proxy type: %v", proxy)
|
||||||
|
|
||||||
authModules := controller.determineAuthModules(proxy)
|
authModules := controller.determineAuthModules(proxy, !controller.config.Experimental.DisableAuthModuleFallback)
|
||||||
|
|
||||||
if len(authModules) == 0 {
|
if len(authModules) == 0 {
|
||||||
return ProxyContext{}, fmt.Errorf("no auth modules supported for proxy: %v", req.Proxy)
|
return ProxyContext{}, fmt.Errorf("no auth modules supported for proxy: %v", req.Proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
var ctx ProxyContext
|
err = controller.ensureNoMultipleAuthModules(c, controller.determineAuthModules(proxy, true))
|
||||||
|
|
||||||
for _, module := range authModules {
|
|
||||||
controller.log.App.Debug().Msgf("Trying to get context from auth module %v", module)
|
|
||||||
ctx, err = controller.getContextFromAuthModule(c, module)
|
|
||||||
if err == nil {
|
|
||||||
controller.log.App.Debug().Msgf("Successfully got context from auth module %v", module)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
controller.log.App.Debug().Msgf("Failed to get context from auth module %v: %v", module, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ProxyContext{}, err
|
return ProxyContext{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ctx *ProxyContext
|
||||||
|
|
||||||
|
for _, module := range authModules {
|
||||||
|
controller.log.App.Debug().Msgf("Trying to get context from auth module %v", module)
|
||||||
|
authModuleCtx, err := controller.getContextFromAuthModule(c, module)
|
||||||
|
if err != nil {
|
||||||
|
controller.log.App.Debug().Msgf("Failed to get context from auth module %v: %v", module, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
controller.log.App.Debug().Msgf("Successfully got context from auth module %v", module)
|
||||||
|
ctx = &authModuleCtx
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx == nil {
|
||||||
|
return ProxyContext{}, fmt.Errorf("failed to get context from any auth module")
|
||||||
|
}
|
||||||
|
|
||||||
// Parse the raw path to populate the cleaned path used for ACLs
|
// Parse the raw path to populate the cleaned path used for ACLs
|
||||||
upath, err := url.Parse(ctx.PathRaw)
|
upath, err := url.Parse(ctx.PathRaw)
|
||||||
|
|
||||||
@@ -577,5 +633,5 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
|
|||||||
|
|
||||||
ctx.IsBrowser = isBrowser
|
ctx.IsBrowser = isBrowser
|
||||||
ctx.ProxyType = proxy
|
ctx.ProxyType = proxy
|
||||||
return ctx, nil
|
return *ctx, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ func TestProxyController(t *testing.T) {
|
|||||||
description: "Ensure forward auth fallback for envoy",
|
description: "Ensure forward auth fallback for envoy",
|
||||||
middlewares: []gin.HandlerFunc{},
|
middlewares: []gin.HandlerFunc{},
|
||||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/hello", nil)
|
req := httptest.NewRequest("HEAD", "/api/auth/envoy", nil)
|
||||||
req.Host = ""
|
req.Host = ""
|
||||||
req.Header.Set("x-forwarded-host", "test.example.com")
|
req.Header.Set("x-forwarded-host", "test.example.com")
|
||||||
req.Header.Set("x-forwarded-proto", "https")
|
req.Header.Set("x-forwarded-proto", "https")
|
||||||
@@ -261,7 +261,7 @@ func TestProxyController(t *testing.T) {
|
|||||||
description: "Ensure extauthz with envoy non browser returns json",
|
description: "Ensure extauthz with envoy non browser returns json",
|
||||||
middlewares: []gin.HandlerFunc{},
|
middlewares: []gin.HandlerFunc{},
|
||||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/hello", nil)
|
req := httptest.NewRequest("HEAD", "/api/auth/envoy", nil)
|
||||||
req.Header.Set("x-forwarded-host", "test.example.com")
|
req.Header.Set("x-forwarded-host", "test.example.com")
|
||||||
req.Header.Set("x-forwarded-proto", "https")
|
req.Header.Set("x-forwarded-proto", "https")
|
||||||
req.Header.Set("x-forwarded-uri", "/hello")
|
req.Header.Set("x-forwarded-uri", "/hello")
|
||||||
@@ -877,6 +877,32 @@ func TestProxyController(t *testing.T) {
|
|||||||
assert.Equal(t, "bar", recorder.Header().Get("x-foo"))
|
assert.Equal(t, "bar", recorder.Header().Get("x-foo"))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: "Forward auth and auth request headers should fail for nginx",
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/auth/nginx", nil)
|
||||||
|
req.Header.Set("x-forwarded-host", "foo.example.com")
|
||||||
|
req.Header.Set("x-forwarded-proto", "https")
|
||||||
|
req.Header.Set("x-forwarded-uri", "/foo?bar=foo")
|
||||||
|
req.Header.Set("x-original-url", "https://foo.example.com/foo?bar=foo")
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Forward auth and ext authz headers should fail for envoy",
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/hello", nil)
|
||||||
|
req.Host = "foo.example.com"
|
||||||
|
req.Header.Set("x-forwarded-host", "foo.example.com")
|
||||||
|
req.Header.Set("x-forwarded-proto", "https")
|
||||||
|
req.Header.Set("x-forwarded-uri", "/foo?bar=foo")
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
store := memory.New()
|
store := memory.New()
|
||||||
@@ -892,6 +918,7 @@ func TestProxyController(t *testing.T) {
|
|||||||
aclsService := service.NewAccessControlsService(service.AccessControlServiceInput{
|
aclsService := service.NewAccessControlsService(service.AccessControlServiceInput{
|
||||||
Log: log,
|
Log: log,
|
||||||
Config: &cfg,
|
Config: &cfg,
|
||||||
|
Runtime: &runtime,
|
||||||
LabelProvider: nil,
|
LabelProvider: nil,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -952,6 +979,7 @@ func TestProxyController(t *testing.T) {
|
|||||||
NewProxyController(ProxyControllerInput{
|
NewProxyController(ProxyControllerInput{
|
||||||
Log: log,
|
Log: log,
|
||||||
RuntimeConfig: &runtime,
|
RuntimeConfig: &runtime,
|
||||||
|
Config: &cfg,
|
||||||
RouterGroup: group,
|
RouterGroup: group,
|
||||||
ACLsService: aclsService,
|
ACLsService: aclsService,
|
||||||
AuthService: authService,
|
AuthService: authService,
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ type LogStreamConfig struct {
|
|||||||
|
|
||||||
type ExperimentalConfig struct {
|
type ExperimentalConfig struct {
|
||||||
OAuthBridgeEnabled bool `description:"Enable the OAuth bridge, uses a new way to format OAuth user information." yaml:"oauthBridgeEnabled,omitempty"`
|
OAuthBridgeEnabled bool `description:"Enable the OAuth bridge, uses a new way to format OAuth user information." yaml:"oauthBridgeEnabled,omitempty"`
|
||||||
|
DisableAuthModuleFallback bool `description:"Disable the fallback to forward_auth modules when auth_request or ext_authz fail." yaml:"disableAuthModuleFallback,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TailscaleConfig struct {
|
type TailscaleConfig struct {
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||||
"github.com/tinyauthapp/tinyauth/pkg/validators"
|
|
||||||
"go.uber.org/dig"
|
"go.uber.org/dig"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ type LabelProvider interface {
|
|||||||
type AccessControlsService struct {
|
type AccessControlsService struct {
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
config *model.Config
|
config *model.Config
|
||||||
|
runtime *model.RuntimeConfig
|
||||||
labelProvider LabelProvider
|
labelProvider LabelProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +28,7 @@ type AccessControlServiceInput struct {
|
|||||||
|
|
||||||
Log *logger.Logger
|
Log *logger.Logger
|
||||||
Config *model.Config
|
Config *model.Config
|
||||||
|
Runtime *model.RuntimeConfig
|
||||||
LabelProvider LabelProvider `optional:"true"`
|
LabelProvider LabelProvider `optional:"true"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,12 +37,38 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
|
|||||||
return &AccessControlsService{
|
return &AccessControlsService{
|
||||||
log: i.Log,
|
log: i.Log,
|
||||||
config: i.Config,
|
config: i.Config,
|
||||||
|
runtime: i.Runtime,
|
||||||
labelProvider: i.LabelProvider,
|
labelProvider: i.LabelProvider,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *AccessControlsService) ensureAscii(str string) bool {
|
||||||
|
for i := 0; i < len(str); i++ {
|
||||||
|
if str[i] > unicode.MaxASCII {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *AccessControlsService) normalizeDomain(domain string) string {
|
||||||
|
if host, _, err := net.SplitHostPort(domain); err == nil {
|
||||||
|
domain = host
|
||||||
|
}
|
||||||
|
domain = strings.TrimRight(domain, ".")
|
||||||
|
return strings.ToLower(domain)
|
||||||
|
}
|
||||||
|
|
||||||
func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) {
|
func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) {
|
||||||
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
|
if !service.ensureAscii(domain) {
|
||||||
|
return nil, errors.New("domain contains non-ascii characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedDomain := service.normalizeDomain(domain)
|
||||||
|
|
||||||
|
if !strings.HasSuffix(normalizedDomain, "."+service.runtime.CookieDomain) && normalizedDomain != service.runtime.CookieDomain {
|
||||||
|
return nil, fmt.Errorf("domain does not match cookie domain, expected %s (or a subdomain), got %s", service.runtime.CookieDomain, domain)
|
||||||
|
}
|
||||||
|
|
||||||
var domainMatch *model.App
|
var domainMatch *model.App
|
||||||
var nameMatch *model.App
|
var nameMatch *model.App
|
||||||
@@ -46,16 +76,18 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator
|
|||||||
|
|
||||||
locatorFunc := func(name string, app *model.App) bool {
|
locatorFunc := func(name string, app *model.App) bool {
|
||||||
if app.Config.Domain != "" {
|
if app.Config.Domain != "" {
|
||||||
err := v.Validate(app.Config.Domain, domain)
|
if !service.ensureAscii(app.Config.Domain) {
|
||||||
if err == nil {
|
service.log.App.Warn().Str("name", name).Str("domain", app.Config.Domain).Msg("Domain contains non-ascii characters, skipping")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if normalizedDomain == service.normalizeDomain(app.Config.Domain) {
|
||||||
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
|
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
|
||||||
domainMatch = app
|
domainMatch = app
|
||||||
return true
|
return true
|
||||||
} else if !errors.Is(err, validators.ErrHostnameMismatch) {
|
|
||||||
service.log.App.Debug().Str("name", name).Err(err).Msg("Domain validation failed")
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) {
|
if strings.HasPrefix(normalizedDomain, strings.ToLower(name+".")) {
|
||||||
service.log.App.Debug().Str("name", name).Msg("Found matching container by app name")
|
service.log.App.Debug().Str("name", name).Msg("Found matching container by app name")
|
||||||
nameMatch = app
|
nameMatch = app
|
||||||
nameMatchedApps = append(nameMatchedApps, name)
|
nameMatchedApps = append(nameMatchedApps, name)
|
||||||
@@ -79,7 +111,7 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(nameMatchedApps) > 1 {
|
if len(nameMatchedApps) > 1 {
|
||||||
service.log.App.Warn().Str("domain", domain).Strs("apps", nameMatchedApps).Msg("Multiple apps matched domain by name, app names must be unique, using last match")
|
return nil, fmt.Errorf("domain matched multiple apps by name prefix, use explicit domain config")
|
||||||
}
|
}
|
||||||
|
|
||||||
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by app name")
|
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by app name")
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||||
|
"github.com/tinyauthapp/tinyauth/internal/test"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,14 +36,25 @@ func TestAccessControlsService(t *testing.T) {
|
|||||||
log := logger.NewLogger().WithTestConfig()
|
log := logger.NewLogger().WithTestConfig()
|
||||||
log.Init()
|
log.Init()
|
||||||
|
|
||||||
|
_, runtime := test.CreateTestConfigs(t)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
domain string
|
domain string
|
||||||
acls map[string]model.App
|
acls map[string]model.App
|
||||||
want *model.App
|
want *model.App
|
||||||
|
errorFunc func(t *testing.T, e error)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "returns ACLs for domain",
|
name: "returns ACLs for domain",
|
||||||
|
domain: "app.example.com",
|
||||||
|
acls: map[string]model.App{
|
||||||
|
"foo": {Config: model.AppConfig{Domain: "app.example.com"}},
|
||||||
|
},
|
||||||
|
want: &model.App{Config: model.AppConfig{Domain: "app.example.com"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "returns ACLs for root domain",
|
||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
acls: map[string]model.App{
|
acls: map[string]model.App{
|
||||||
"foo": {Config: model.AppConfig{Domain: "example.com"}},
|
"foo": {Config: model.AppConfig{Domain: "example.com"}},
|
||||||
@@ -65,20 +78,11 @@ func TestAccessControlsService(t *testing.T) {
|
|||||||
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
|
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "returns ACLs for non-ascii domain",
|
name: "returns error for non-ascii domain",
|
||||||
domain: "bücher.example.com",
|
domain: "bücher.example.com",
|
||||||
acls: map[string]model.App{
|
errorFunc: func(t *testing.T, e error) {
|
||||||
"foo": {Config: model.AppConfig{Domain: "bücher.example.com"}},
|
assert.ErrorContains(t, e, "domain contains non-ascii characters")
|
||||||
},
|
},
|
||||||
want: &model.App{Config: model.AppConfig{Domain: "bücher.example.com"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "returns ACLs for punycode domain and non-ascii config",
|
|
||||||
domain: "bücher.example.com",
|
|
||||||
acls: map[string]model.App{
|
|
||||||
"foo": {Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
|
|
||||||
},
|
|
||||||
want: &model.App{Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "returns ACLs with case-insensitive matching",
|
name: "returns ACLs with case-insensitive matching",
|
||||||
@@ -110,6 +114,33 @@ func TestAccessControlsService(t *testing.T) {
|
|||||||
acls: map[string]model.App{},
|
acls: map[string]model.App{},
|
||||||
want: nil,
|
want: nil,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "App in domain not matching with the cookie domain should return nothing with name matching",
|
||||||
|
domain: "foo.bad_example.com",
|
||||||
|
acls: map[string]model.App{
|
||||||
|
"foo": {
|
||||||
|
Path: model.AppPath{Allow: "/foo"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: nil,
|
||||||
|
errorFunc: func(t *testing.T, e error) {
|
||||||
|
assert.ErrorContains(t, e, "domain does not match cookie domain")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "App in domain not matching with the cookie domain should return nothing with domain matching",
|
||||||
|
domain: "foo.bad_example.com",
|
||||||
|
acls: map[string]model.App{
|
||||||
|
"foo": {
|
||||||
|
Path: model.AppPath{Allow: "/foo"},
|
||||||
|
Config: model.AppConfig{Domain: "foo.bad_example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: nil,
|
||||||
|
errorFunc: func(t *testing.T, e error) {
|
||||||
|
assert.ErrorContains(t, e, "domain does not match cookie domain")
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// run once for a mock provider
|
// run once for a mock provider
|
||||||
@@ -118,10 +149,15 @@ func TestAccessControlsService(t *testing.T) {
|
|||||||
mock := newMockProvider(test.acls, false)
|
mock := newMockProvider(test.acls, false)
|
||||||
acls := NewAccessControlsService(AccessControlServiceInput{
|
acls := NewAccessControlsService(AccessControlServiceInput{
|
||||||
Log: log,
|
Log: log,
|
||||||
|
Runtime: &runtime,
|
||||||
Config: &model.Config{},
|
Config: &model.Config{},
|
||||||
LabelProvider: mock,
|
LabelProvider: mock,
|
||||||
})
|
})
|
||||||
app, err := acls.getACLs(test.domain, mock.Lookup)
|
app, err := acls.getACLs(test.domain, mock.Lookup)
|
||||||
|
if test.errorFunc != nil {
|
||||||
|
test.errorFunc(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, test.want, app)
|
require.Equal(t, test.want, app)
|
||||||
})
|
})
|
||||||
@@ -132,11 +168,16 @@ func TestAccessControlsService(t *testing.T) {
|
|||||||
t.Run(test.name+"(staticACLs)", func(t *testing.T) {
|
t.Run(test.name+"(staticACLs)", func(t *testing.T) {
|
||||||
acls := NewAccessControlsService(AccessControlServiceInput{
|
acls := NewAccessControlsService(AccessControlServiceInput{
|
||||||
Log: log,
|
Log: log,
|
||||||
|
Runtime: &runtime,
|
||||||
Config: &model.Config{
|
Config: &model.Config{
|
||||||
Apps: test.acls,
|
Apps: test.acls,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app, err := acls.lookupStaticACLs(test.domain)
|
app, err := acls.lookupStaticACLs(test.domain)
|
||||||
|
if test.errorFunc != nil {
|
||||||
|
test.errorFunc(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, test.want, app)
|
require.Equal(t, test.want, app)
|
||||||
})
|
})
|
||||||
@@ -146,15 +187,31 @@ func TestAccessControlsService(t *testing.T) {
|
|||||||
mock := newMockProvider(map[string]model.App{}, true)
|
mock := newMockProvider(map[string]model.App{}, true)
|
||||||
acls := NewAccessControlsService(AccessControlServiceInput{
|
acls := NewAccessControlsService(AccessControlServiceInput{
|
||||||
Log: log,
|
Log: log,
|
||||||
|
Runtime: &runtime,
|
||||||
Config: &model.Config{},
|
Config: &model.Config{},
|
||||||
})
|
})
|
||||||
_, err := acls.getACLs("example.com", mock.Lookup)
|
_, err := acls.getACLs("example.com", mock.Lookup)
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// get acls should return an error when multiple apps with the same domain exist
|
||||||
|
acls = NewAccessControlsService(AccessControlServiceInput{
|
||||||
|
Log: log,
|
||||||
|
Runtime: &runtime,
|
||||||
|
Config: &model.Config{
|
||||||
|
Apps: map[string]model.App{
|
||||||
|
"foo": {Path: model.AppPath{Allow: "/foo"}},
|
||||||
|
"foo.bar": {Path: model.AppPath{Allow: "/bar"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_, err = acls.GetAccessControls("foo.bar.example.com")
|
||||||
|
assert.ErrorContains(t, err, "domain matched multiple apps by name prefix, use explicit domain config")
|
||||||
|
|
||||||
// get access controls should get acls from
|
// get access controls should get acls from
|
||||||
// static when static acls are configured
|
// static when static acls are configured
|
||||||
acls = NewAccessControlsService(AccessControlServiceInput{
|
acls = NewAccessControlsService(AccessControlServiceInput{
|
||||||
Log: log,
|
Log: log,
|
||||||
|
Runtime: &runtime,
|
||||||
Config: &model.Config{
|
Config: &model.Config{
|
||||||
Apps: map[string]model.App{
|
Apps: map[string]model.App{
|
||||||
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
|
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
|
||||||
@@ -163,12 +220,12 @@ func TestAccessControlsService(t *testing.T) {
|
|||||||
})
|
})
|
||||||
app, err := acls.GetAccessControls("foo.example.com")
|
app, err := acls.GetAccessControls("foo.example.com")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "foo.example.com"}}, app)
|
assert.Equal(t, &model.App{Config: model.AppConfig{Domain: "foo.example.com"}}, app)
|
||||||
|
|
||||||
// should return nil for no apps
|
// should return nil for no apps
|
||||||
app, err = acls.GetAccessControls("bar.example.com")
|
app, err = acls.GetAccessControls("bar.example.com")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Nil(t, app)
|
assert.Nil(t, app)
|
||||||
|
|
||||||
// Should use label provider if available
|
// Should use label provider if available
|
||||||
mock = newMockProvider(map[string]model.App{
|
mock = newMockProvider(map[string]model.App{
|
||||||
@@ -178,10 +235,11 @@ func TestAccessControlsService(t *testing.T) {
|
|||||||
}, false)
|
}, false)
|
||||||
acls = NewAccessControlsService(AccessControlServiceInput{
|
acls = NewAccessControlsService(AccessControlServiceInput{
|
||||||
Log: log,
|
Log: log,
|
||||||
|
Runtime: &runtime,
|
||||||
Config: &model.Config{},
|
Config: &model.Config{},
|
||||||
LabelProvider: mock,
|
LabelProvider: mock,
|
||||||
})
|
})
|
||||||
app, err = acls.GetAccessControls("bar.example.com")
|
app, err = acls.GetAccessControls("bar.example.com")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "bar.example.com"}}, app)
|
assert.Equal(t, &model.App{Config: model.AppConfig{Domain: "bar.example.com"}}, app)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,16 @@ func SafeParseAppURL(str string) (string, error) {
|
|||||||
return "", fmt.Errorf("ip addresses not allowed")
|
return "", fmt.Errorf("ip addresses not allowed")
|
||||||
}
|
}
|
||||||
|
|
||||||
hostname, err = idna.Lookup.ToASCII(hostname)
|
i := idna.New(
|
||||||
|
idna.MapForLookup(),
|
||||||
|
idna.Transitional(false),
|
||||||
|
idna.BidiRule(),
|
||||||
|
idna.StrictDomainName(false),
|
||||||
|
idna.CheckHyphens(true),
|
||||||
|
idna.CheckJoiners(false),
|
||||||
|
)
|
||||||
|
|
||||||
|
hostname, err = i.ToASCII(hostname)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to convert hostname to ascii: %w", err)
|
return "", fmt.Errorf("failed to convert hostname to ascii: %w", err)
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ func TestSafeParseAPPURL(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, expected, result)
|
assert.Equal(t, expected, result)
|
||||||
|
|
||||||
|
// Underscores
|
||||||
|
appURL = "http://sub_tinyauth.app"
|
||||||
|
expected = "http://sub_tinyauth.app"
|
||||||
|
result, err = utils.SafeParseAppURL(appURL)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, result)
|
||||||
|
|
||||||
// Lowercase
|
// Lowercase
|
||||||
appURL = "HTTP://SUb.tinyAUth.aPP"
|
appURL = "HTTP://SUb.tinyAUth.aPP"
|
||||||
expected = "http://sub.tinyauth.app"
|
expected = "http://sub.tinyauth.app"
|
||||||
@@ -66,7 +73,7 @@ func TestSafeParseAPPURL(t *testing.T) {
|
|||||||
assert.ErrorContains(t, err, "invalid url")
|
assert.ErrorContains(t, err, "invalid url")
|
||||||
|
|
||||||
// Invalid punycode
|
// Invalid punycode
|
||||||
appURL = "http://ab--cd.example.com"
|
appURL = "http://xn--h-kva.example.com"
|
||||||
_, err = utils.SafeParseAppURL(appURL)
|
_, err = utils.SafeParseAppURL(appURL)
|
||||||
assert.ErrorContains(t, err, "failed to convert hostname to ascii")
|
assert.ErrorContains(t, err, "failed to convert hostname to ascii")
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/net/idna"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Errors
|
// Errors
|
||||||
@@ -115,14 +113,10 @@ func (v *DomainValidator) getURL(i string) (*url.URL, error) {
|
|||||||
|
|
||||||
func (v *DomainValidator) getHostname(hostname string) (string, error) {
|
func (v *DomainValidator) getHostname(hostname string) (string, error) {
|
||||||
hostname = strings.ToLower(hostname)
|
hostname = strings.ToLower(hostname)
|
||||||
hostname = strings.TrimSuffix(hostname, ".")
|
hostname = strings.TrimRight(hostname, ".")
|
||||||
if net.ParseIP(hostname) != nil {
|
if net.ParseIP(hostname) != nil {
|
||||||
return "", fmt.Errorf("ip addresses are not supported")
|
return "", fmt.Errorf("ip addresses are not supported")
|
||||||
}
|
}
|
||||||
hostname, err := idna.Lookup.ToASCII(hostname)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to convert hostname to ascii: %w", err)
|
|
||||||
}
|
|
||||||
return hostname, nil
|
return hostname, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,18 +101,6 @@ func TestDomainValidator_SafeHostname(t *testing.T) {
|
|||||||
assert.ErrorContains(t, e, "ip addresses are not supported")
|
assert.ErrorContains(t, e, "ip addresses are not supported")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
description: "Domains with unicode characters should be allowed",
|
|
||||||
input: "bücher.example.com",
|
|
||||||
expected: "xn--bcher-kva.example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Invalid IDNA domain should fail",
|
|
||||||
input: "ab--cd.example.com",
|
|
||||||
errorFunc: func(t *testing.T, e error) {
|
|
||||||
assert.ErrorContains(t, e, "invalid label")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
description: "With port enabled without any port should work",
|
description: "With port enabled without any port should work",
|
||||||
options: DomainValidatorOptions{WithPort: true},
|
options: DomainValidatorOptions{WithPort: true},
|
||||||
@@ -194,22 +182,6 @@ func TestDomainValidator_Validate(t *testing.T) {
|
|||||||
expected: "https://example.com:443",
|
expected: "https://example.com:443",
|
||||||
actual: "https://example.com:443",
|
actual: "https://example.com:443",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
description: "Failure to format expected domain should fail",
|
|
||||||
expected: "ab--cd.example.com",
|
|
||||||
actual: "example.com",
|
|
||||||
errorFunc: func(t *testing.T, e error) {
|
|
||||||
assert.ErrorContains(t, e, "idna: invalid label")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Failure to format check domain should fail",
|
|
||||||
expected: "example.com",
|
|
||||||
actual: "ab--cd.example.com",
|
|
||||||
errorFunc: func(t *testing.T, e error) {
|
|
||||||
assert.ErrorContains(t, e, "idna: invalid label")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
description: "Valid domains with matching schemes and ports should pass",
|
description: "Valid domains with matching schemes and ports should pass",
|
||||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}, WithPort: true},
|
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}, WithPort: true},
|
||||||
@@ -236,16 +208,6 @@ func TestDomainValidator_Validate(t *testing.T) {
|
|||||||
actual: "example.com",
|
actual: "example.com",
|
||||||
expected: "example.com",
|
expected: "example.com",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
description: "Unicode valid domains should pass",
|
|
||||||
expected: "xn--bcher-kva.example.com",
|
|
||||||
actual: "bücher.example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Unicode valid domains should pass (reverse)",
|
|
||||||
expected: "bücher.example.com",
|
|
||||||
actual: "xn--bcher-kva.example.com",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
description: "Non matching hostnames should fail",
|
description: "Non matching hostnames should fail",
|
||||||
expected: "example.com",
|
expected: "example.com",
|
||||||
|
|||||||
Reference in New Issue
Block a user