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.
220 lines
5.8 KiB
Go
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
|
|
}
|