Files
wild-central/internal/authelia/oidc.go
Paul Payne d796a79f79 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.
2026-07-12 11:47:37 +00:00

220 lines
5.8 KiB
Go

package authelia
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-central/internal/storage"
)
// OIDCClient represents a registered OIDC client application
type OIDCClient struct {
ID string `yaml:"client_id" json:"clientId"`
Name string `yaml:"client_name" json:"clientName"`
Secret string `yaml:"client_secret" json:"clientSecret,omitempty"` // pbkdf2 hash in config, cleartext on create response
RedirectURIs []string `yaml:"redirect_uris" json:"redirectUris"`
Scopes []string `yaml:"scopes" json:"scopes"`
Policy string `yaml:"authorization_policy" json:"authorizationPolicy"`
ConsentMode string `yaml:"consent_mode,omitempty" json:"consentMode,omitempty"`
}
// OIDCClientUpdate holds partial updates for an OIDC client
type OIDCClientUpdate struct {
Name *string `json:"clientName,omitempty"`
RedirectURIs []string `json:"redirectUris,omitempty"`
Scopes []string `json:"scopes,omitempty"`
Policy *string `json:"authorizationPolicy,omitempty"`
ConsentMode *string `json:"consentMode,omitempty"`
}
// oidcClientsFile is the top-level structure of the OIDC clients file
type oidcClientsFile struct {
Clients []OIDCClient `yaml:"clients"`
}
// ListOIDCClients returns all registered OIDC clients (secrets redacted)
func (m *Manager) ListOIDCClients() ([]OIDCClient, error) {
clients, err := m.readOIDCClients()
if err != nil {
return nil, err
}
// Redact secrets for listing
for i := range clients {
clients[i].Secret = ""
}
return clients, nil
}
// ReadOIDCClients returns all clients with secrets (for config generation)
func (m *Manager) ReadOIDCClients() ([]OIDCClient, error) {
return m.readOIDCClients()
}
// AddOIDCClient adds a new OIDC client. Returns the cleartext secret (shown once).
func (m *Manager) AddOIDCClient(client OIDCClient) (cleartextSecret string, err error) {
if client.ID == "" {
return "", fmt.Errorf("client_id is required")
}
if client.Name == "" {
return "", fmt.Errorf("client_name is required")
}
if len(client.RedirectURIs) == 0 {
return "", fmt.Errorf("at least one redirect_uri is required")
}
lockPath := m.configPath() + ".lock"
err = storage.WithLock(lockPath, func() error {
clients, readErr := m.readOIDCClients()
if readErr != nil {
return readErr
}
for _, c := range clients {
if c.ID == client.ID {
return fmt.Errorf("client %q already exists", client.ID)
}
}
// Generate client secret
secret, genErr := GenerateSecret(32)
if genErr != nil {
return fmt.Errorf("generating client secret: %w", genErr)
}
cleartextSecret = secret
// Hash for storage
client.Secret = hashClientSecret(secret)
// Default scopes
if len(client.Scopes) == 0 {
client.Scopes = []string{"openid", "profile", "email", "groups"}
}
if client.Policy == "" {
client.Policy = "one_factor"
}
return m.writeOIDCClients(append(clients, client))
})
return
}
// UpdateOIDCClient applies partial updates to an OIDC client
func (m *Manager) UpdateOIDCClient(clientID string, updates OIDCClientUpdate) error {
lockPath := m.configPath() + ".lock"
return storage.WithLock(lockPath, func() error {
clients, err := m.readOIDCClients()
if err != nil {
return err
}
found := false
for i, c := range clients {
if c.ID == clientID {
if updates.Name != nil {
clients[i].Name = *updates.Name
}
if updates.RedirectURIs != nil {
clients[i].RedirectURIs = updates.RedirectURIs
}
if updates.Scopes != nil {
clients[i].Scopes = updates.Scopes
}
if updates.Policy != nil {
clients[i].Policy = *updates.Policy
}
if updates.ConsentMode != nil {
clients[i].ConsentMode = *updates.ConsentMode
}
found = true
break
}
}
if !found {
return fmt.Errorf("client %q not found", clientID)
}
return m.writeOIDCClients(clients)
})
}
// DeleteOIDCClient removes an OIDC client by ID
func (m *Manager) DeleteOIDCClient(clientID string) error {
lockPath := m.configPath() + ".lock"
return storage.WithLock(lockPath, func() error {
clients, err := m.readOIDCClients()
if err != nil {
return err
}
var filtered []OIDCClient
found := false
for _, c := range clients {
if c.ID == clientID {
found = true
continue
}
filtered = append(filtered, c)
}
if !found {
return fmt.Errorf("client %q not found", clientID)
}
return m.writeOIDCClients(filtered)
})
}
// OIDCClientCount returns the number of registered OIDC clients
func (m *Manager) OIDCClientCount() int {
clients, err := m.readOIDCClients()
if err != nil {
return 0
}
return len(clients)
}
// readOIDCClients reads OIDC clients from the dedicated clients file
func (m *Manager) readOIDCClients() ([]OIDCClient, error) {
data, err := os.ReadFile(m.oidcClientsPath())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading oidc clients: %w", err)
}
var f oidcClientsFile
if err := yaml.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parsing oidc clients: %w", err)
}
return f.Clients, nil
}
// writeOIDCClients writes the OIDC clients to the dedicated clients file
func (m *Manager) writeOIDCClients(clients []OIDCClient) error {
f := oidcClientsFile{Clients: clients}
data, err := yaml.Marshal(f)
if err != nil {
return fmt.Errorf("marshaling oidc clients: %w", err)
}
tmpPath := m.oidcClientsPath() + ".tmp"
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
return fmt.Errorf("writing temp oidc clients: %w", err)
}
if err := os.Rename(tmpPath, m.oidcClientsPath()); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("installing oidc clients: %w", err)
}
return nil
}
// hashClientSecret wraps the secret with $plaintext$ prefix.
// Authelia accepts this format and handles hashing internally.
func hashClientSecret(secret string) string {
return "$plaintext$" + secret
}