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:
219
internal/authelia/oidc.go
Normal file
219
internal/authelia/oidc.go
Normal file
@@ -0,0 +1,219 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user