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 }