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.
223 lines
5.8 KiB
Go
223 lines
5.8 KiB
Go
package authelia
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ConfigOpts holds parameters for generating Authelia's configuration.yml
|
|
type ConfigOpts struct {
|
|
Domain string // auth portal domain (e.g. auth.payne.io)
|
|
JWTSecret string // identity validation JWT secret
|
|
SessionSecret string // session cookie secret
|
|
StorageEncKey string // storage encryption key (>=20 chars)
|
|
OIDCHMACSecret string // OIDC HMAC secret
|
|
DefaultPolicy string // "one_factor" or "two_factor"
|
|
SessionDomain string // cookie domain for SSO (e.g. payne.io) — forward-auth only works for subdomains of this
|
|
DataDir string // authelia data directory path
|
|
ListenAddr string // listen address (default: 127.0.0.1:9091)
|
|
OIDCClients []OIDCClient // registered OIDC clients
|
|
}
|
|
|
|
// GenerateConfig builds Authelia's configuration.yml and writes it atomically.
|
|
func (m *Manager) GenerateConfig(opts ConfigOpts) error {
|
|
if err := m.EnsureDataDir(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if opts.ListenAddr == "" {
|
|
opts.ListenAddr = "127.0.0.1:9091"
|
|
}
|
|
if opts.DefaultPolicy == "" {
|
|
opts.DefaultPolicy = "one_factor"
|
|
}
|
|
if opts.DataDir == "" {
|
|
opts.DataDir = m.dataDir
|
|
}
|
|
|
|
cfg := m.buildConfig(opts)
|
|
|
|
data, err := yaml.Marshal(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling authelia config: %w", err)
|
|
}
|
|
|
|
header := "# Authelia Configuration\n# Managed by Wild Central — do not edit manually\n---\n"
|
|
content := header + string(data)
|
|
|
|
tmpPath := m.configPath() + ".tmp"
|
|
if err := os.WriteFile(tmpPath, []byte(content), 0600); err != nil {
|
|
return fmt.Errorf("writing temp config: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tmpPath, m.configPath()); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("installing config: %w", err)
|
|
}
|
|
|
|
slog.Info("authelia config written", "component", "authelia", "path", m.configPath())
|
|
return nil
|
|
}
|
|
|
|
// EnsureJWKS generates an RSA key pair for OIDC token signing if one doesn't exist.
|
|
func (m *Manager) EnsureJWKS() error {
|
|
if _, err := os.Stat(m.jwksPath()); err == nil {
|
|
return nil // already exists
|
|
}
|
|
|
|
key, err := rsa.GenerateKey(rand.Reader, 4096)
|
|
if err != nil {
|
|
return fmt.Errorf("generating RSA key: %w", err)
|
|
}
|
|
|
|
keyBytes := x509.MarshalPKCS1PrivateKey(key)
|
|
pemBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyBytes}
|
|
pemData := pem.EncodeToMemory(pemBlock)
|
|
|
|
if err := os.WriteFile(m.jwksPath(), pemData, 0600); err != nil {
|
|
return fmt.Errorf("writing JWKS key: %w", err)
|
|
}
|
|
|
|
slog.Info("authelia JWKS key pair generated", "component", "authelia", "path", m.jwksPath())
|
|
return nil
|
|
}
|
|
|
|
// buildConfig constructs the Authelia config as a nested map for YAML marshaling.
|
|
func (m *Manager) buildConfig(opts ConfigOpts) map[string]any {
|
|
cfg := map[string]any{
|
|
"theme": "auto",
|
|
|
|
"server": map[string]any{
|
|
"address": "tcp://" + opts.ListenAddr + "/",
|
|
},
|
|
|
|
"log": map[string]any{
|
|
"level": "info",
|
|
},
|
|
|
|
"totp": map[string]any{
|
|
"issuer": "Wild Cloud",
|
|
},
|
|
|
|
"authentication_backend": map[string]any{
|
|
"file": map[string]any{
|
|
"path": m.usersDBPath(),
|
|
"password": map[string]any{
|
|
"algorithm": "argon2id",
|
|
"iterations": 3,
|
|
"memory": 65536,
|
|
"parallelism": 2,
|
|
"key_length": 32,
|
|
"salt_length": 16,
|
|
},
|
|
},
|
|
},
|
|
|
|
"access_control": map[string]any{
|
|
"default_policy": opts.DefaultPolicy,
|
|
},
|
|
|
|
"session": map[string]any{
|
|
"name": "wild_central_session",
|
|
"secret": opts.SessionSecret,
|
|
"cookies": []map[string]any{
|
|
{
|
|
"domain": opts.SessionDomain,
|
|
"authelia_url": "https://" + opts.Domain,
|
|
},
|
|
},
|
|
},
|
|
|
|
"storage": map[string]any{
|
|
"encryption_key": opts.StorageEncKey,
|
|
"local": map[string]any{
|
|
"path": m.sqlitePath(),
|
|
},
|
|
},
|
|
|
|
"notifier": map[string]any{
|
|
"filesystem": map[string]any{
|
|
"filename": m.notificationPath(),
|
|
},
|
|
},
|
|
|
|
"identity_validation": map[string]any{
|
|
"reset_password": map[string]any{
|
|
"jwt_secret": opts.JWTSecret,
|
|
},
|
|
},
|
|
}
|
|
|
|
// OIDC provider configuration — only emitted when clients exist.
|
|
// Authelia requires at least one client when the OIDC section is present.
|
|
if opts.OIDCHMACSecret != "" && len(opts.OIDCClients) > 0 {
|
|
oidcCfg := map[string]any{
|
|
"hmac_secret": opts.OIDCHMACSecret,
|
|
}
|
|
|
|
// Inline the JWKS private key directly (avoids needing template filters)
|
|
if jwksData, err := os.ReadFile(m.jwksPath()); err == nil {
|
|
oidcCfg["jwks"] = []map[string]any{
|
|
{
|
|
"key_id": "wild-central",
|
|
"algorithm": "RS256",
|
|
"use": "sig",
|
|
"key": string(jwksData),
|
|
},
|
|
}
|
|
}
|
|
|
|
{
|
|
var clients []map[string]any
|
|
for _, c := range opts.OIDCClients {
|
|
client := map[string]any{
|
|
"client_id": c.ID,
|
|
"client_name": c.Name,
|
|
"client_secret": c.Secret, // already hashed
|
|
"redirect_uris": c.RedirectURIs,
|
|
"scopes": c.Scopes,
|
|
"authorization_policy": c.Policy,
|
|
}
|
|
if c.ConsentMode != "" {
|
|
client["consent_mode"] = c.ConsentMode
|
|
}
|
|
clients = append(clients, client)
|
|
}
|
|
oidcCfg["clients"] = clients
|
|
}
|
|
|
|
cfg["identity_providers"] = map[string]any{
|
|
"oidc": oidcCfg,
|
|
}
|
|
}
|
|
|
|
return cfg
|
|
}
|
|
|
|
// GenerateSecret creates a cryptographically random hex string of the given byte length.
|
|
func GenerateSecret(byteLen int) (string, error) {
|
|
b := make([]byte, byteLen)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("generating random bytes: %w", err)
|
|
}
|
|
return fmt.Sprintf("%x", b), nil
|
|
}
|
|
|
|
// SessionDomainFromAuthDomain extracts the parent domain for session cookie scoping.
|
|
// "auth.payne.io" -> "payne.io", "auth.sub.example.com" -> "sub.example.com"
|
|
func SessionDomainFromAuthDomain(authDomain string) string {
|
|
parts := strings.SplitN(authDomain, ".", 2)
|
|
if len(parts) == 2 {
|
|
return parts[1]
|
|
}
|
|
return authDomain
|
|
}
|