Files
wild-central/internal/authelia/manager_test.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

353 lines
8.4 KiB
Go

package authelia
import (
"os"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestGenerateConfig(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
opts := ConfigOpts{
Domain: "auth.payne.io",
JWTSecret: "test-jwt-secret",
SessionSecret: "test-session-secret",
StorageEncKey: "test-storage-encryption-key-min-20",
OIDCHMACSecret: "test-oidc-hmac-secret",
DefaultPolicy: "one_factor",
SessionDomain: "payne.io",
DataDir: dir,
ListenAddr: "127.0.0.1:9091",
}
if err := m.GenerateConfig(opts); err != nil {
t.Fatalf("GenerateConfig: %v", err)
}
// Verify file exists
configPath := filepath.Join(dir, "configuration.yml")
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("reading config: %v", err)
}
content := string(data)
// Check key values are present
checks := []string{
"tcp://127.0.0.1:9091/",
"one_factor",
"payne.io",
"auth.payne.io",
"Wild Cloud",
"argon2id",
"test-session-secret",
"test-storage-encryption-key-min-20",
}
for _, check := range checks {
if !strings.Contains(content, check) {
t.Errorf("config missing expected value: %q", check)
}
}
// Verify it's valid YAML
var parsed map[string]any
if err := yaml.Unmarshal(data, &parsed); err != nil {
t.Errorf("config is not valid YAML: %v", err)
}
// Check file permissions
info, _ := os.Stat(configPath)
if info.Mode().Perm() != 0600 {
t.Errorf("config permissions = %o, want 0600", info.Mode().Perm())
}
}
func TestGenerateConfigWithOIDCClients(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
opts := ConfigOpts{
Domain: "auth.payne.io",
JWTSecret: "jwt",
SessionSecret: "session",
StorageEncKey: "storage-enc-key-20chars",
OIDCHMACSecret: "oidc-hmac",
SessionDomain: "payne.io",
OIDCClients: []OIDCClient{
{
ID: "gitea",
Name: "Gitea",
Secret: "$pbkdf2-sha512$hashed",
RedirectURIs: []string{"https://git.payne.io/user/oauth2/authelia/callback"},
Scopes: []string{"openid", "profile", "email"},
Policy: "one_factor",
},
},
}
if err := m.GenerateConfig(opts); err != nil {
t.Fatalf("GenerateConfig: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "configuration.yml"))
content := string(data)
if !strings.Contains(content, "gitea") {
t.Error("config should contain OIDC client 'gitea'")
}
if !strings.Contains(content, "git.payne.io") {
t.Error("config should contain redirect URI")
}
}
func TestGenerateConfigDefaults(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
// Minimal opts — should use defaults
opts := ConfigOpts{
Domain: "auth.example.com",
JWTSecret: "jwt",
SessionSecret: "session",
StorageEncKey: "enc-key-twenty-chars",
SessionDomain: "example.com",
}
if err := m.GenerateConfig(opts); err != nil {
t.Fatalf("GenerateConfig: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "configuration.yml"))
content := string(data)
// Should default to one_factor
if !strings.Contains(content, "one_factor") {
t.Error("should default to one_factor policy")
}
// Should default to 127.0.0.1:9091
if !strings.Contains(content, "127.0.0.1:9091") {
t.Error("should default to 127.0.0.1:9091")
}
// Without OIDCHMACSecret, should not have identity_providers
if strings.Contains(content, "identity_providers") {
t.Error("should not include OIDC section without HMAC secret")
}
}
func TestUserCRUD(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
// List empty
users, err := m.ListUsers()
if err != nil {
t.Fatalf("ListUsers empty: %v", err)
}
if len(users) != 0 {
t.Errorf("expected 0 users, got %d", len(users))
}
// Create
err = m.CreateUser(User{
Username: "admin",
Displayname: "Admin User",
Password: "supersecret",
Email: "admin@example.com",
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
// List after create
users, err = m.ListUsers()
if err != nil {
t.Fatalf("ListUsers: %v", err)
}
if len(users) != 1 {
t.Fatalf("expected 1 user, got %d", len(users))
}
if users[0].Username != "admin" {
t.Errorf("username = %q, want %q", users[0].Username, "admin")
}
if users[0].Displayname != "Admin User" {
t.Errorf("displayname = %q, want %q", users[0].Displayname, "Admin User")
}
if users[0].Email != "admin@example.com" {
t.Errorf("email = %q, want %q", users[0].Email, "admin@example.com")
}
// Password should be omitted in listing
if users[0].Password != "" {
t.Error("password should be empty in listing")
}
// Duplicate create should fail
err = m.CreateUser(User{Username: "admin", Password: "x", Displayname: "dup"})
if err == nil {
t.Error("duplicate create should fail")
}
// Update
newName := "Super Admin"
err = m.UpdateUser("admin", UserUpdate{Displayname: &newName})
if err != nil {
t.Fatalf("UpdateUser: %v", err)
}
users, _ = m.ListUsers()
if users[0].Displayname != "Super Admin" {
t.Errorf("after update, displayname = %q, want %q", users[0].Displayname, "Super Admin")
}
// Update nonexistent user
err = m.UpdateUser("nobody", UserUpdate{Displayname: &newName})
if err == nil {
t.Error("update nonexistent should fail")
}
// Delete
err = m.DeleteUser("admin")
if err != nil {
t.Fatalf("DeleteUser: %v", err)
}
users, _ = m.ListUsers()
if len(users) != 0 {
t.Errorf("after delete, expected 0 users, got %d", len(users))
}
// Delete nonexistent
err = m.DeleteUser("admin")
if err == nil {
t.Error("delete nonexistent should fail")
}
}
func TestCreateUserValidation(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
if err := m.CreateUser(User{Password: "x", Displayname: "y"}); err == nil {
t.Error("empty username should fail")
}
if err := m.CreateUser(User{Username: "x", Displayname: "y"}); err == nil {
t.Error("empty password should fail")
}
}
func TestHashPassword(t *testing.T) {
hash, err := hashPassword("testpassword")
if err != nil {
t.Fatalf("hashPassword: %v", err)
}
if !strings.HasPrefix(hash, "$argon2id$v=19$") {
t.Errorf("hash should start with $argon2id$v=19$, got %q", hash[:30])
}
// Two hashes of the same password should differ (different salt)
hash2, _ := hashPassword("testpassword")
if hash == hash2 {
t.Error("two hashes of the same password should differ (random salt)")
}
}
func TestUserCount(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
if m.UserCount() != 0 {
t.Error("empty db should have 0 users")
}
m.CreateUser(User{Username: "a", Password: "p", Displayname: "A"})
m.CreateUser(User{Username: "b", Password: "p", Displayname: "B"})
if m.UserCount() != 2 {
t.Errorf("expected 2 users, got %d", m.UserCount())
}
}
func TestEnsureUsersDB(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
if err := m.EnsureUsersDB(); err != nil {
t.Fatalf("EnsureUsersDB: %v", err)
}
// File should exist
if _, err := os.Stat(m.usersDBPath()); err != nil {
t.Errorf("users db file should exist: %v", err)
}
// Should be idempotent
if err := m.EnsureUsersDB(); err != nil {
t.Fatalf("EnsureUsersDB (second call): %v", err)
}
}
func TestEnsureJWKS(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
if err := m.EnsureJWKS(); err != nil {
t.Fatalf("EnsureJWKS: %v", err)
}
// File should exist
data, err := os.ReadFile(m.jwksPath())
if err != nil {
t.Fatalf("reading JWKS: %v", err)
}
if !strings.Contains(string(data), "RSA PRIVATE KEY") {
t.Error("JWKS file should contain RSA private key")
}
// Should be idempotent (doesn't regenerate)
if err := m.EnsureJWKS(); err != nil {
t.Fatalf("EnsureJWKS (second call): %v", err)
}
}
func TestGenerateSecret(t *testing.T) {
s1, err := GenerateSecret(32)
if err != nil {
t.Fatalf("GenerateSecret: %v", err)
}
if len(s1) != 64 { // 32 bytes = 64 hex chars
t.Errorf("secret length = %d, want 64", len(s1))
}
s2, _ := GenerateSecret(32)
if s1 == s2 {
t.Error("two generated secrets should differ")
}
}
func TestSessionDomainFromAuthDomain(t *testing.T) {
tests := []struct {
input string
want string
}{
{"auth.payne.io", "payne.io"},
{"auth.sub.example.com", "sub.example.com"},
{"localhost", "localhost"},
}
for _, tt := range tests {
got := SessionDomainFromAuthDomain(tt.input)
if got != tt.want {
t.Errorf("SessionDomainFromAuthDomain(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestIsInstalled(t *testing.T) {
m := NewManager(t.TempDir())
// Just verify it doesn't panic — the result depends on whether authelia is installed
_ = m.IsInstalled()
}