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:
2026-07-12 11:47:37 +00:00
parent 24bb976652
commit d796a79f79
27 changed files with 3097 additions and 11 deletions

View File

@@ -0,0 +1,442 @@
package v1
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/internal/authelia"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/domains"
)
// AutheliaStatus returns the current Authelia service status
func (api *API) AutheliaStatus(w http.ResponseWriter, r *http.Request) {
status, err := api.authelia.GetStatus()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get authelia status: %v", err))
return
}
state, _ := config.LoadState(api.statePath())
enabled := false
domain := ""
defaultPolicy := ""
if state != nil {
enabled = state.Cloud.Authelia.Enabled
domain = state.Cloud.Authelia.Domain
defaultPolicy = state.Cloud.Authelia.DefaultPolicy
}
respondJSON(w, http.StatusOK, map[string]any{
"active": status.Active,
"version": status.Version,
"enabled": enabled,
"domain": domain,
"defaultPolicy": defaultPolicy,
"userCount": api.authelia.UserCount(),
"clientCount": api.authelia.OIDCClientCount(),
"installed": api.authelia.IsInstalled(),
})
}
// AutheliaGetConfig returns the raw Authelia configuration for the advanced view
func (api *API) AutheliaGetConfig(w http.ResponseWriter, r *http.Request) {
content, err := api.authelia.ReadConfig()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read authelia config: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"content": content})
}
// AutheliaUpdateConfig enables/disables Authelia and updates its configuration
func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) {
var req struct {
Enabled *bool `json:"enabled"`
Domain *string `json:"domain"`
DefaultPolicy *string `json:"defaultPolicy"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
state, err := config.LoadState(api.statePath())
if err != nil {
state = &config.State{}
}
if req.Domain != nil {
state.Cloud.Authelia.Domain = *req.Domain
}
if req.DefaultPolicy != nil {
state.Cloud.Authelia.DefaultPolicy = *req.DefaultPolicy
}
if req.Enabled != nil {
state.Cloud.Authelia.Enabled = *req.Enabled
}
// Enabling Authelia
if state.Cloud.Authelia.Enabled {
if !api.authelia.IsInstalled() {
respondError(w, http.StatusBadRequest, "Authelia is not installed. Install it first (apt install authelia).")
return
}
if state.Cloud.Authelia.Domain == "" {
respondError(w, http.StatusBadRequest, "Auth portal domain is required when enabling Authelia")
return
}
if !api.authelia.HasLuaScript() {
respondError(w, http.StatusBadRequest,
"HAProxy auth-request Lua script not found at /etc/haproxy/lua/haproxy-auth-request.lua. "+
"Install it: sudo mkdir -p /etc/haproxy/lua && sudo curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua "+
"https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua")
return
}
// Ensure data directory exists
if err := api.authelia.EnsureDataDir(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create data directory: %v", err))
return
}
// Auto-generate secrets if missing
if err := api.ensureAutheliaSecrets(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate secrets: %v", err))
return
}
// Generate JWKS key pair for OIDC
if err := api.authelia.EnsureJWKS(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate JWKS: %v", err))
return
}
// Ensure user database exists
if err := api.authelia.EnsureUsersDB(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to initialize user database: %v", err))
return
}
// Generate config
if err := api.generateAutheliaConfig(state); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate config: %v", err))
return
}
// Register auth portal domain
api.ensureAuthDomain(state.Cloud.Authelia.Domain)
// Start service — requires at least one user
if api.authelia.UserCount() > 0 {
if err := api.authelia.RestartService(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to start Authelia: %v", err))
return
}
}
} else {
// Disabling — stop service and deregister domain
_ = api.authelia.StopService()
api.deregisterAuthDomain()
}
if err := config.SaveState(state, api.statePath()); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
// Trigger reconciliation to update HAProxy
go api.reconcileNetworking()
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
respondMessage(w, http.StatusOK, "Authelia configuration updated")
}
// AutheliaRestart restarts the Authelia service
func (api *API) AutheliaRestart(w http.ResponseWriter, r *http.Request) {
if err := api.authelia.RestartService(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to restart authelia: %v", err))
return
}
api.broadcastAutheliaEvent("authelia:restart", "Authelia service restarted")
respondMessage(w, http.StatusOK, "Authelia restarted")
}
// --- User management ---
// AutheliaListUsers returns all Authelia users (passwords omitted)
func (api *API) AutheliaListUsers(w http.ResponseWriter, r *http.Request) {
users, err := api.authelia.ListUsers()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list users: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"users": users})
}
// AutheliaCreateUser creates a new Authelia user
func (api *API) AutheliaCreateUser(w http.ResponseWriter, r *http.Request) {
var user authelia.User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := api.authelia.CreateUser(user); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to create user: %v", err))
return
}
slog.Info("authelia user created", "username", user.Username)
// If Authelia is enabled but not running (was waiting for first user), start it
state, _ := config.LoadState(api.statePath())
if state != nil && state.Cloud.Authelia.Enabled {
status, _ := api.authelia.GetStatus()
if status != nil && !status.Active {
if err := api.authelia.RestartService(); err != nil {
slog.Warn("failed to start authelia after first user", "error", err)
}
}
}
api.broadcastAutheliaEvent("authelia:user", fmt.Sprintf("User %q created", user.Username))
respondMessage(w, http.StatusCreated, fmt.Sprintf("User %q created", user.Username))
}
// AutheliaUpdateUser updates an existing Authelia user
func (api *API) AutheliaUpdateUser(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
var updates authelia.UserUpdate
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := api.authelia.UpdateUser(username, updates); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to update user: %v", err))
return
}
slog.Info("authelia user updated", "username", username)
respondMessage(w, http.StatusOK, fmt.Sprintf("User %q updated", username))
}
// AutheliaDeleteUser deletes an Authelia user
func (api *API) AutheliaDeleteUser(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
if err := api.authelia.DeleteUser(username); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to delete user: %v", err))
return
}
slog.Info("authelia user deleted", "username", username)
api.broadcastAutheliaEvent("authelia:user", fmt.Sprintf("User %q deleted", username))
respondMessage(w, http.StatusOK, fmt.Sprintf("User %q deleted", username))
}
// --- OIDC client management ---
// AutheliaListOIDCClients returns all registered OIDC clients (secrets redacted)
func (api *API) AutheliaListOIDCClients(w http.ResponseWriter, r *http.Request) {
clients, err := api.authelia.ListOIDCClients()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list OIDC clients: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"clients": clients})
}
// AutheliaCreateOIDCClient creates a new OIDC client and returns the secret once
func (api *API) AutheliaCreateOIDCClient(w http.ResponseWriter, r *http.Request) {
var client authelia.OIDCClient
if err := json.NewDecoder(r.Body).Decode(&client); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
secret, err := api.authelia.AddOIDCClient(client)
if err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to create OIDC client: %v", err))
return
}
// Regenerate full config (adds OIDC section) and restart
if err := api.regenAndRestartAuthelia(); err != nil {
slog.Warn("failed to restart authelia after OIDC client create", "error", err)
}
slog.Info("authelia OIDC client created", "clientId", client.ID)
api.broadcastAutheliaEvent("authelia:oidc", fmt.Sprintf("OIDC client %q created", client.ID))
respondJSON(w, http.StatusCreated, map[string]any{
"clientId": client.ID,
"clientSecret": secret,
"message": "OIDC client created. Save the client secret — it will not be shown again.",
})
}
// AutheliaUpdateOIDCClient updates an OIDC client
func (api *API) AutheliaUpdateOIDCClient(w http.ResponseWriter, r *http.Request) {
clientID := mux.Vars(r)["clientId"]
var updates authelia.OIDCClientUpdate
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := api.authelia.UpdateOIDCClient(clientID, updates); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to update OIDC client: %v", err))
return
}
if err := api.regenAndRestartAuthelia(); err != nil {
slog.Warn("failed to restart authelia after OIDC client update", "error", err)
}
slog.Info("authelia OIDC client updated", "clientId", clientID)
respondMessage(w, http.StatusOK, fmt.Sprintf("OIDC client %q updated", clientID))
}
// AutheliaDeleteOIDCClient deletes an OIDC client
func (api *API) AutheliaDeleteOIDCClient(w http.ResponseWriter, r *http.Request) {
clientID := mux.Vars(r)["clientId"]
if err := api.authelia.DeleteOIDCClient(clientID); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to delete OIDC client: %v", err))
return
}
if err := api.regenAndRestartAuthelia(); err != nil {
slog.Warn("failed to restart authelia after OIDC client delete", "error", err)
}
slog.Info("authelia OIDC client deleted", "clientId", clientID)
api.broadcastAutheliaEvent("authelia:oidc", fmt.Sprintf("OIDC client %q deleted", clientID))
respondMessage(w, http.StatusOK, fmt.Sprintf("OIDC client %q deleted", clientID))
}
// --- Internal helpers ---
// regenAndRestartAuthelia regenerates the full Authelia config and restarts the service.
// Used after OIDC client changes to ensure the config includes the OIDC section.
func (api *API) regenAndRestartAuthelia() error {
state, err := config.LoadState(api.statePath())
if err != nil || !state.Cloud.Authelia.Enabled {
return nil
}
if err := api.generateAutheliaConfig(state); err != nil {
return fmt.Errorf("regenerating config: %w", err)
}
if api.authelia.UserCount() > 0 {
return api.authelia.RestartService()
}
return nil
}
// ensureAutheliaSecrets generates Authelia secrets if they don't already exist
func (api *API) ensureAutheliaSecrets() error {
keys := []string{
"authelia.jwtSecret",
"authelia.sessionSecret",
"authelia.storageEncryptionKey",
"authelia.oidcHmacSecret",
}
for _, key := range keys {
existing, _ := api.secrets.GetSecret(key)
if existing != "" {
continue
}
secret, err := authelia.GenerateSecret(32)
if err != nil {
return fmt.Errorf("generating %s: %w", key, err)
}
if err := api.secrets.SetSecret(key, secret); err != nil {
return fmt.Errorf("saving %s: %w", key, err)
}
}
return nil
}
// generateAutheliaConfig reads secrets and state, then generates the Authelia config
func (api *API) generateAutheliaConfig(state *config.State) error {
jwtSecret, _ := api.secrets.GetSecret("authelia.jwtSecret")
sessionSecret, _ := api.secrets.GetSecret("authelia.sessionSecret")
storageEncKey, _ := api.secrets.GetSecret("authelia.storageEncryptionKey")
oidcHmac, _ := api.secrets.GetSecret("authelia.oidcHmacSecret")
// Read OIDC clients from the clients file (with secrets for config generation)
clients, _ := api.authelia.ReadOIDCClients()
defaultPolicy := state.Cloud.Authelia.DefaultPolicy
if defaultPolicy == "" {
defaultPolicy = "one_factor"
}
sessionDomain := authelia.SessionDomainFromAuthDomain(state.Cloud.Authelia.Domain)
opts := authelia.ConfigOpts{
Domain: state.Cloud.Authelia.Domain,
JWTSecret: jwtSecret,
SessionSecret: sessionSecret,
StorageEncKey: storageEncKey,
OIDCHMACSecret: oidcHmac,
DefaultPolicy: defaultPolicy,
SessionDomain: sessionDomain,
OIDCClients: clients,
}
return api.authelia.GenerateConfig(opts)
}
// ensureAuthDomain registers the Authelia login portal as a domain
func (api *API) ensureAuthDomain(authDomain string) {
if authDomain == "" {
return
}
// Clean up stale authelia registrations
doms, _ := api.domains.List()
for _, dom := range doms {
if dom.Source == "authelia" && dom.DomainName != authDomain {
_ = api.domains.Deregister(dom.DomainName)
}
}
_ = api.domains.Register(domains.Domain{
DomainName: authDomain,
Source: "authelia",
Routes: []domains.Route{
{
Backend: domains.Backend{
Address: "127.0.0.1:9091",
Type: domains.BackendHTTP,
},
Headers: &domains.HeaderConfig{
Request: map[string]string{
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": authDomain,
},
},
},
},
TLS: domains.TLSTerminate,
})
}
// deregisterAuthDomain removes all authelia-sourced domain registrations
func (api *API) deregisterAuthDomain() {
doms, _ := api.domains.List()
for _, dom := range doms {
if dom.Source == "authelia" {
_ = api.domains.Deregister(dom.DomainName)
}
}
}