Add Authelia as centralized authentication and OIDC provider
Authelia runs as a managed native service on Wild Central, providing two integration patterns for network services: - Forward-auth via HAProxy for apps without native SSO (Lua auth-request script intercepts requests, redirects unauthenticated users to login portal) - OIDC provider for apps with native support (Gitea, Grafana, etc.) Backend: new internal/authelia/ package with service manager, config generation, file-based user management (argon2id), and OIDC client management. API endpoints for status, config, users CRUD, and OIDC clients CRUD. HAProxy: config generator extended with lua-load, auth-request directives, and Authelia backend. Auth directives scoped to session cookie domain — only subdomains of the auth portal's parent are eligible for forward-auth. Frontend: Authentication page with enable/disable, user management, OIDC client management (add/edit/delete), and protected domains toggles. Advanced subsystem page for raw config view. Dashboard service card and sidebar entries.
This commit is contained in:
@@ -39,6 +39,8 @@ type HTTPRouteBackend struct {
|
||||
HealthPath string // optional health check path
|
||||
Headers *domains.HeaderConfig // per-route headers
|
||||
IPAllow []string // per-route CIDR whitelist
|
||||
AuthEnabled bool // forward-auth protection via Authelia
|
||||
AuthPolicy string // one_factor, two_factor (for access control)
|
||||
}
|
||||
|
||||
// HTTPRoute represents an L7 HTTP reverse proxy route.
|
||||
@@ -80,8 +82,12 @@ func (m *Manager) GetConfigPath() string {
|
||||
|
||||
// GenerateOpts holds optional parameters for HAProxy config generation.
|
||||
type GenerateOpts struct {
|
||||
HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central)
|
||||
CertsDir string // directory containing per-domain PEM files for L7 TLS
|
||||
HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central)
|
||||
CertsDir string // directory containing per-domain PEM files for L7 TLS
|
||||
AuthEnabled bool // global Authelia forward-auth toggle
|
||||
AuthBackend string // Authelia backend address (e.g. "127.0.0.1:9091")
|
||||
AuthDomain string // Authelia login portal domain (excluded from protection)
|
||||
AuthSessionDomain string // session cookie scope (e.g. "payne.io") — only subdomains are eligible for forward-auth
|
||||
}
|
||||
|
||||
// GenerateWithOpts creates a complete HAProxy configuration with full options.
|
||||
@@ -97,7 +103,13 @@ global
|
||||
stats timeout 30s
|
||||
maxconn 50000
|
||||
daemon
|
||||
`)
|
||||
if opts.AuthEnabled {
|
||||
sb.WriteString(" lua-prepend-path /etc/haproxy/lua/?.lua\n")
|
||||
sb.WriteString(" lua-load /etc/haproxy/lua/haproxy-auth-request.lua\n")
|
||||
}
|
||||
|
||||
sb.WriteString(`
|
||||
defaults
|
||||
log global
|
||||
mode tcp
|
||||
@@ -284,7 +296,48 @@ frontend http_in
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
// Authelia forward-auth directives for protected routes
|
||||
if opts.AuthEnabled && opts.AuthBackend != "" {
|
||||
authDomainACL := ""
|
||||
if opts.AuthDomain != "" {
|
||||
authDomainACL = "host_" + sanitizeName(opts.AuthDomain)
|
||||
}
|
||||
|
||||
for _, httpRoute := range opts.HTTPRoutes {
|
||||
hostACL := "host_" + sanitizeName(httpRoute.Name)
|
||||
|
||||
// Skip auth portal domain (would cause infinite redirect)
|
||||
if hostACL == authDomainACL {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip domains outside the auth session scope — forward-auth
|
||||
// requires shared cookies, which only work within the same
|
||||
// parent domain. Other domains should use OIDC instead.
|
||||
if opts.AuthSessionDomain != "" &&
|
||||
httpRoute.Domain != opts.AuthSessionDomain &&
|
||||
!strings.HasSuffix(httpRoute.Domain, "."+opts.AuthSessionDomain) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if any route on this domain has auth enabled
|
||||
hasAuth := false
|
||||
for _, rb := range httpRoute.Routes {
|
||||
if rb.AuthEnabled {
|
||||
hasAuth = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAuth {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, " # auth: %s\n", httpRoute.Domain)
|
||||
fmt.Fprintf(&sb, " http-request lua.auth-request be_authelia /api/authz/forward-auth if %s\n", hostACL)
|
||||
fmt.Fprintf(&sb, " http-request redirect location https://%s/?rd=https://%%[hdr(host)]%%[path] if %s !{ var(txn.auth_response_successful) -m bool }\n", opts.AuthDomain, hostACL)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Routing rules: path-specific first, then catch-all
|
||||
for _, httpRoute := range opts.HTTPRoutes {
|
||||
@@ -333,6 +386,15 @@ frontend http_in
|
||||
}
|
||||
}
|
||||
|
||||
// Authelia backend for forward-auth
|
||||
if opts.AuthEnabled && opts.AuthBackend != "" {
|
||||
sb.WriteString("# Authelia forward-auth backend\n")
|
||||
sb.WriteString("backend be_authelia\n")
|
||||
sb.WriteString(" mode http\n")
|
||||
fmt.Fprintf(&sb, " server s0 %s\n", opts.AuthBackend)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
for _, route := range custom {
|
||||
fmt.Fprintf(&sb, "# Custom route: %s\n", route.Name)
|
||||
fmt.Fprintf(&sb, "frontend fe_%s\n", sanitizeName(route.Name))
|
||||
|
||||
@@ -833,3 +833,213 @@ line3
|
||||
t.Errorf("expected 0 broken services when no markers, got %d", len(broken))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Authelia forward-auth tests ---
|
||||
|
||||
func TestGenerateWithOpts_AuthLuaLoad(t *testing.T) {
|
||||
m := NewManager("")
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
AuthEnabled: true,
|
||||
AuthBackend: "127.0.0.1:9091",
|
||||
AuthDomain: "auth.payne.io",
|
||||
})
|
||||
|
||||
if !strings.Contains(cfg, "lua-load /etc/haproxy/lua/haproxy-auth-request.lua") {
|
||||
t.Error("config should contain lua-load directive when auth is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithOpts_NoAuthLuaLoadWhenDisabled(t *testing.T) {
|
||||
m := NewManager("")
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
AuthEnabled: false,
|
||||
})
|
||||
|
||||
if strings.Contains(cfg, "lua-load") {
|
||||
t.Error("config should NOT contain lua-load when auth is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithOpts_AuthBackend(t *testing.T) {
|
||||
m := NewManager("")
|
||||
httpRoutes := []HTTPRoute{
|
||||
{
|
||||
Name: "app.payne.io",
|
||||
Domain: "app.payne.io",
|
||||
Routes: []HTTPRouteBackend{
|
||||
{Backend: "127.0.0.1:8080", AuthEnabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
CertsDir: "/tmp/certs/",
|
||||
AuthEnabled: true,
|
||||
AuthBackend: "127.0.0.1:9091",
|
||||
AuthDomain: "auth.payne.io",
|
||||
})
|
||||
|
||||
if !strings.Contains(cfg, "backend be_authelia") {
|
||||
t.Error("config should contain Authelia backend")
|
||||
}
|
||||
if !strings.Contains(cfg, "server s0 127.0.0.1:9091") {
|
||||
t.Error("config should contain Authelia server address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithOpts_AuthInterceptDirective(t *testing.T) {
|
||||
m := NewManager("")
|
||||
httpRoutes := []HTTPRoute{
|
||||
{
|
||||
Name: "app.payne.io",
|
||||
Domain: "app.payne.io",
|
||||
Routes: []HTTPRouteBackend{
|
||||
{Backend: "127.0.0.1:8080", AuthEnabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
CertsDir: "/tmp/certs/",
|
||||
AuthEnabled: true,
|
||||
AuthBackend: "127.0.0.1:9091",
|
||||
AuthDomain: "auth.payne.io",
|
||||
})
|
||||
|
||||
if !strings.Contains(cfg, "lua.auth-request be_authelia") {
|
||||
t.Error("config should contain auth-intercept directive for protected route")
|
||||
}
|
||||
if !strings.Contains(cfg, "auth.payne.io/?rd=") {
|
||||
t.Error("config should contain redirect to auth portal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithOpts_AuthDomainExempt(t *testing.T) {
|
||||
m := NewManager("")
|
||||
httpRoutes := []HTTPRoute{
|
||||
{
|
||||
Name: "auth.payne.io",
|
||||
Domain: "auth.payne.io",
|
||||
Routes: []HTTPRouteBackend{
|
||||
{Backend: "127.0.0.1:9091", AuthEnabled: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "app.payne.io",
|
||||
Domain: "app.payne.io",
|
||||
Routes: []HTTPRouteBackend{
|
||||
{Backend: "127.0.0.1:8080", AuthEnabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
CertsDir: "/tmp/certs/",
|
||||
AuthEnabled: true,
|
||||
AuthBackend: "127.0.0.1:9091",
|
||||
AuthDomain: "auth.payne.io",
|
||||
})
|
||||
|
||||
// Count auth-intercept directives — should be 1 (for app), not 2 (auth domain exempt)
|
||||
count := strings.Count(cfg, "lua.auth-request")
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 auth-intercept directive (auth domain exempt), got %d", count)
|
||||
}
|
||||
|
||||
// The auth comment should only appear for app.payne.io
|
||||
if !strings.Contains(cfg, "# auth: app.payne.io") {
|
||||
t.Error("should have auth comment for app.payne.io")
|
||||
}
|
||||
if strings.Contains(cfg, "# auth: auth.payne.io") {
|
||||
t.Error("should NOT have auth comment for auth.payne.io (exempt)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithOpts_NoAuthForUnprotectedRoutes(t *testing.T) {
|
||||
m := NewManager("")
|
||||
httpRoutes := []HTTPRoute{
|
||||
{
|
||||
Name: "public.payne.io",
|
||||
Domain: "public.payne.io",
|
||||
Routes: []HTTPRouteBackend{
|
||||
{Backend: "127.0.0.1:8080", AuthEnabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
CertsDir: "/tmp/certs/",
|
||||
AuthEnabled: true,
|
||||
AuthBackend: "127.0.0.1:9091",
|
||||
AuthDomain: "auth.payne.io",
|
||||
})
|
||||
|
||||
if strings.Contains(cfg, "lua.auth-request") {
|
||||
t.Error("should NOT contain auth-intercept for unprotected routes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithOpts_AuthDisabledNoDirectives(t *testing.T) {
|
||||
m := NewManager("")
|
||||
httpRoutes := []HTTPRoute{
|
||||
{
|
||||
Name: "app.payne.io",
|
||||
Domain: "app.payne.io",
|
||||
Routes: []HTTPRouteBackend{
|
||||
{Backend: "127.0.0.1:8080", AuthEnabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
CertsDir: "/tmp/certs/",
|
||||
AuthEnabled: false,
|
||||
})
|
||||
|
||||
if strings.Contains(cfg, "lua.auth-request") {
|
||||
t.Error("should NOT contain auth directives when auth is globally disabled")
|
||||
}
|
||||
if strings.Contains(cfg, "be_authelia") {
|
||||
t.Error("should NOT contain authelia backend when auth is globally disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithOpts_AuthWithHeaders(t *testing.T) {
|
||||
m := NewManager("")
|
||||
httpRoutes := []HTTPRoute{
|
||||
{
|
||||
Name: "app.payne.io",
|
||||
Domain: "app.payne.io",
|
||||
Routes: []HTTPRouteBackend{
|
||||
{
|
||||
Backend: "127.0.0.1:8080",
|
||||
AuthEnabled: true,
|
||||
Headers: &domains.HeaderConfig{
|
||||
Request: map[string]string{"X-Custom": "value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
CertsDir: "/tmp/certs/",
|
||||
AuthEnabled: true,
|
||||
AuthBackend: "127.0.0.1:9091",
|
||||
AuthDomain: "auth.payne.io",
|
||||
})
|
||||
|
||||
// Should have both auth and headers
|
||||
if !strings.Contains(cfg, "lua.auth-request") {
|
||||
t.Error("should contain auth-intercept")
|
||||
}
|
||||
if !strings.Contains(cfg, "X-Custom") {
|
||||
t.Error("should still contain custom headers alongside auth")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user