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