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 SMTPHost string // SMTP server host (empty = use filesystem notifier) SMTPPort int // SMTP server port (default: 587) SMTPUsername string // SMTP login username (defaults to sender if empty) SMTPSender string // From address SMTPPassword string // SMTP password (from secrets) } // ValidateConfigOpts checks that required fields are present and valid. func ValidateConfigOpts(opts ConfigOpts) error { if opts.Domain == "" { return fmt.Errorf("auth portal domain is required") } if opts.JWTSecret == "" { return fmt.Errorf("JWT secret is required") } if opts.SessionSecret == "" { return fmt.Errorf("session secret is required") } if len(opts.StorageEncKey) < 20 { return fmt.Errorf("storage encryption key must be at least 20 characters, got %d", len(opts.StorageEncKey)) } return nil } // GenerateConfig validates options, builds Authelia's configuration.yml, and writes it atomically. func (m *Manager) GenerateConfig(opts ConfigOpts) error { if err := ValidateConfigOpts(opts); err != nil { return fmt.Errorf("config validation: %w", err) } 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": buildNotifier(opts, 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 } // buildNotifier returns the notifier config — SMTP if configured, filesystem otherwise. func buildNotifier(opts ConfigOpts, filesystemPath string) map[string]any { if opts.SMTPHost != "" && opts.SMTPSender != "" { username := opts.SMTPUsername if username == "" { username = opts.SMTPSender } port := opts.SMTPPort if port == 0 { port = 587 } // Use the modern address format: submission:// for STARTTLS on 587, smtps:// for implicit TLS on 465 scheme := "submission" if port == 465 { scheme = "smtps" } smtp := map[string]any{ "address": fmt.Sprintf("%s://%s:%d", scheme, opts.SMTPHost, port), "sender": opts.SMTPSender, "username": username, "password": opts.SMTPPassword, "tls": map[string]any{ "server_name": opts.SMTPHost, }, } return map[string]any{"smtp": smtp} } return map[string]any{ "filesystem": map[string]any{ "filename": filesystemPath, }, } } // 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 }