From 51c6eaf435064fbe9b5f3b04ad1f480f69bb8ded Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Mon, 18 May 2026 19:12:21 +0000 Subject: [PATCH] Fix #36: Add Authelia as core IAM service for shards Authelia is added as a permanent core service in every shard, providing forward-auth (via Traefik middleware) and OIDC (for OAuth clients) with a file-based user backend that hot-reloads on change. Key changes: - docker-compose.yml: Add authelia/authelia:4.38 service, mounting ${FREESHARD_DIR}/core/authelia as /config - data/authelia/: Jinja2 templates for configuration.yml and the initial users_database.yml rendered at shard startup - shard_core/service/authelia.py: Secrets bootstrap (stored in kv_store, generated once), config rendering, and CRUD service layer for the YAML user database with argon2id password hashing - shard_core/web/protected/authelia_users.py: REST endpoints at /protected/authelia/users (list, get, create, update, delete) - shard_core/service/traefik_dynamic_config.py: Add authelia-forwardauth middleware (http://authelia:9091/api/authz/forward-auth), authelia service, and auth. public router; switch app routers from the old auth middleware to authelia-forwardauth - shard_core/app_factory.py: Call ensure_authelia_secrets and render_authelia_config during startup lifespan - tests/: Update traefik dyn spec test, add authelia user CRUD tests Co-Authored-By: Claude Sonnet 4.6 --- data/authelia/configuration.yml.j2 | 58 ++++++ data/authelia/users_database.yml.j2 | 2 + docker-compose.yml | 14 ++ shard_core/app_factory.py | 7 + shard_core/service/authelia.py | 188 +++++++++++++++++ shard_core/service/traefik_dynamic_config.py | 29 ++- shard_core/web/protected/__init__.py | 2 + shard_core/web/protected/authelia_users.py | 98 +++++++++ tests/test_authelia_users.py | 206 +++++++++++++++++++ tests/test_call_peer.py | 6 +- tests/test_traefik_dyn_spec.py | 9 + 11 files changed, 617 insertions(+), 2 deletions(-) create mode 100644 data/authelia/configuration.yml.j2 create mode 100644 data/authelia/users_database.yml.j2 create mode 100644 shard_core/service/authelia.py create mode 100644 shard_core/web/protected/authelia_users.py create mode 100644 tests/test_authelia_users.py diff --git a/data/authelia/configuration.yml.j2 b/data/authelia/configuration.yml.j2 new file mode 100644 index 0000000..877e89d --- /dev/null +++ b/data/authelia/configuration.yml.j2 @@ -0,0 +1,58 @@ +--- +server: + address: 'tcp://:9091/' + +log: + level: 'info' + +authentication_backend: + file: + path: '/config/users_database.yml' + watch: true + +session: + secret: '{{ session_secret }}' + cookies: + - name: 'authelia_session' + domain: '{{ domain }}' + authelia_url: '{{ protocol }}://auth.{{ domain }}' + expiration: '1h' + inactivity: '5m' + +storage: + encryption_key: '{{ storage_encryption_key }}' + local: + path: '/config/db.sqlite3' + +identity_validation: + reset_password: + jwt_secret: '{{ jwt_secret }}' + +regulation: + max_retries: 3 + find_time: '2 minutes' + ban_time: '5 minutes' + +access_control: + default_policy: 'one_factor' + +notifier: + filesystem: + filename: '/config/notifications.txt' + +identity_providers: + oidc: + hmac_secret: '{{ oidc_hmac_secret }}' + jwks: + - key_id: 'main' + algorithm: 'RS256' + use: 'sig' + key: '/config/oidc.pem' + cors: + endpoints: + - 'userinfo' + - 'authorization' + - 'token' + - 'revocation' + - 'introspection' + clients: [] diff --git a/data/authelia/users_database.yml.j2 b/data/authelia/users_database.yml.j2 new file mode 100644 index 0000000..79ab91c --- /dev/null +++ b/data/authelia/users_database.yml.j2 @@ -0,0 +1,2 @@ +--- +users: {} diff --git a/docker-compose.yml b/docker-compose.yml index fcc2d4d..1dffa0c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,20 @@ services: postgres: condition: service_healthy + authelia: + image: authelia/authelia:4.38 + container_name: authelia + restart: always + volumes: + - ${FREESHARD_DIR:?}/core/authelia:/config + networks: + - portal + expose: + - 9091 + depends_on: + shard_core: + condition: service_healthy + web-terminal: image: ghcr.io/freeshardbase/web-terminal:0.37.4 container_name: web-terminal diff --git a/shard_core/app_factory.py b/shard_core/app_factory.py index 5c68cd9..8c4ab9e 100644 --- a/shard_core/app_factory.py +++ b/shard_core/app_factory.py @@ -15,6 +15,7 @@ from .database import terminals as db_terminals from .service import ( app_installation, + authelia, identity, app_lifecycle, peer, @@ -34,6 +35,7 @@ docker_shutdown_all_apps, scheduled_docker_prune_images, ) +from .data_model.identity import SafeIdentity from .service.backup import start_backup from .service.pairing import make_pairing_code from .settings import settings @@ -86,6 +88,11 @@ async def lifespan(_): await migration.migrate() await app_installation.refresh_init_apps() await backup.ensure_backup_passphrase() + + i = await identity.get_default_identity() + portal = SafeIdentity.from_identity(i) + await authelia.ensure_authelia_secrets() + await authelia.render_authelia_config(portal) try: await portal_controller.refresh_profile() except (ConnectionError, HTTPError, ValidationError) as e: diff --git a/shard_core/service/authelia.py b/shard_core/service/authelia.py new file mode 100644 index 0000000..91bb30b --- /dev/null +++ b/shard_core/service/authelia.py @@ -0,0 +1,188 @@ +import base64 +import logging +import os +import secrets +import threading +from pathlib import Path +from typing import Optional + +import jinja2 +import yaml +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.kdf.argon2 import Argon2id + +from shard_core.database import database +from shard_core.data_model.identity import SafeIdentity +from shard_core.settings import settings + +log = logging.getLogger(__name__) + +STORE_KEY_JWT_SECRET = "authelia_jwt_secret" +STORE_KEY_SESSION_SECRET = "authelia_session_secret" +STORE_KEY_STORAGE_ENCRYPTION_KEY = "authelia_storage_encryption_key" +STORE_KEY_OIDC_HMAC_SECRET = "authelia_oidc_hmac_secret" +STORE_KEY_OIDC_PRIVATE_KEY = "authelia_oidc_private_key" + +_write_lock = threading.Lock() + + +async def ensure_authelia_secrets() -> None: + for key in [ + STORE_KEY_JWT_SECRET, + STORE_KEY_SESSION_SECRET, + STORE_KEY_STORAGE_ENCRYPTION_KEY, + STORE_KEY_OIDC_HMAC_SECRET, + ]: + try: + await database.get_value(key) + except KeyError: + await database.set_value(key, secrets.token_urlsafe(64)) + log.info(f"Generated Authelia secret: {key}") + + try: + await database.get_value(STORE_KEY_OIDC_PRIVATE_KEY) + except KeyError: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096) + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + await database.set_value(STORE_KEY_OIDC_PRIVATE_KEY, pem.decode()) + log.info("Generated Authelia OIDC private key") + + +async def render_authelia_config(portal: SafeIdentity) -> None: + jwt_secret = await database.get_value(STORE_KEY_JWT_SECRET) + session_secret = await database.get_value(STORE_KEY_SESSION_SECRET) + storage_encryption_key = await database.get_value(STORE_KEY_STORAGE_ENCRYPTION_KEY) + oidc_hmac_secret = await database.get_value(STORE_KEY_OIDC_HMAC_SECRET) + oidc_private_key = await database.get_value(STORE_KEY_OIDC_PRIVATE_KEY) + + template_dir = Path.cwd() / "data" / "authelia" + config_dir = _get_config_dir() + config_dir.mkdir(parents=True, exist_ok=True) + + template = jinja2.Template((template_dir / "configuration.yml.j2").read_text()) + protocol = "http" if settings().traefik.disable_ssl else "https" + config_content = template.render( + domain=portal.domain, + protocol=protocol, + jwt_secret=jwt_secret, + session_secret=session_secret, + storage_encryption_key=storage_encryption_key, + oidc_hmac_secret=oidc_hmac_secret, + ) + (config_dir / "configuration.yml").write_text(config_content) + + (config_dir / "oidc.pem").write_text(oidc_private_key) + + users_db_path = config_dir / "users_database.yml" + if not users_db_path.exists(): + template = jinja2.Template((template_dir / "users_database.yml.j2").read_text()) + users_db_path.write_text(template.render()) + log.info("Created Authelia users_database.yml") + + log.info("Rendered Authelia configuration") + + +def _get_config_dir() -> Path: + return Path(settings().path_root) / "core" / "authelia" + + +def get_users_db_path() -> Path: + return _get_config_dir() / "users_database.yml" + + +def list_users() -> dict[str, dict]: + path = get_users_db_path() + if not path.exists(): + return {} + data = yaml.safe_load(path.read_text()) or {} + return data.get("users") or {} + + +def get_user(username: str) -> Optional[dict]: + return list_users().get(username) + + +def create_user( + username: str, + display_name: str, + email: str, + password: str, + groups: list[str] | None = None, +) -> None: + with _write_lock: + path = get_users_db_path() + data = _read_users_db(path) + if username in data["users"]: + raise ValueError(f"User '{username}' already exists") + data["users"][username] = { + "displayname": display_name, + "email": email, + "password": hash_password(password), + "groups": groups or [], + } + _write_users_db(data, path) + + +def update_user( + username: str, + *, + display_name: str | None = None, + email: str | None = None, + password: str | None = None, + groups: list[str] | None = None, +) -> None: + with _write_lock: + path = get_users_db_path() + data = _read_users_db(path) + if username not in data["users"]: + raise KeyError(username) + user = data["users"][username] + if display_name is not None: + user["displayname"] = display_name + if email is not None: + user["email"] = email + if password is not None: + user["password"] = hash_password(password) + if groups is not None: + user["groups"] = groups + _write_users_db(data, path) + + +def delete_user(username: str) -> None: + with _write_lock: + path = get_users_db_path() + data = _read_users_db(path) + if username not in data["users"]: + raise KeyError(username) + del data["users"][username] + _write_users_db(data, path) + + +def hash_password(plain: str) -> str: + salt = os.urandom(16) + kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536) + digest = kdf.derive(plain.encode()) + salt_b64 = base64.b64encode(salt).decode().rstrip("=") + hash_b64 = base64.b64encode(digest).decode().rstrip("=") + return f"$argon2id$v=19$m=65536,t=3,p=4${salt_b64}${hash_b64}" + + +def _read_users_db(path: Path) -> dict: + if not path.exists(): + return {"users": {}} + data = yaml.safe_load(path.read_text()) or {} + if "users" not in data or data["users"] is None: + data["users"] = {} + return data + + +def _write_users_db(data: dict, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True)) + os.replace(tmp, path) diff --git a/shard_core/service/traefik_dynamic_config.py b/shard_core/service/traefik_dynamic_config.py index bab844e..edd6e15 100644 --- a/shard_core/service/traefik_dynamic_config.py +++ b/shard_core/service/traefik_dynamic_config.py @@ -76,6 +76,12 @@ def _add_http_section(model: t.Model, portal: SafeIdentity): middlewares=["auth-private"], tls=make_http_cert_resolver(portal), ), + "authelia": t.HttpRouter( + rule=f"Host(`auth.{portal.domain}`)", + entryPoints=[http_entrypoint], + service="authelia", + tls=make_http_cert_resolver(portal), + ), } _middlewares = { @@ -122,6 +128,20 @@ def _add_http_section(model: t.Model, portal: SafeIdentity): ) ) ), + "authelia-forwardauth": t.HttpMiddleware( + root=t.HttpMiddlewareItem9( + forwardAuth=t.ForwardAuthMiddleware( + address="http://authelia:9091/api/authz/forward-auth", + trustForwardHeader=True, + authResponseHeaders=[ + "Remote-User", + "Remote-Groups", + "Remote-Name", + "Remote-Email", + ], + ) + ) + ), "app-error": t.HttpMiddleware( root=t.HttpMiddlewareItem8( errors=t.ErrorsMiddleware( @@ -147,6 +167,13 @@ def _add_http_section(model: t.Model, portal: SafeIdentity): ) ) ), + "authelia": t.HttpService( + root=t.HttpServiceItem( + loadBalancer=t.HttpLoadBalancerService( + servers=[t.Server(url="http://authelia:9091/")] + ) + ) + ), } model.http = t.Http(routers=_routers, middlewares=_middlewares, services=_services) @@ -168,7 +195,7 @@ def _add_router( rule=f"Host(`{app.name}.{portal.domain}`)", entryPoints=[http_entrypoint], service=f"{app.name}_{ep_value}", - middlewares=["app-error", "auth"], + middlewares=["app-error", "authelia-forwardauth"], tls=make_http_cert_resolver(portal), ) elif entrypoint.entrypoint_port == EntrypointPort.MQTTS_1883: diff --git a/shard_core/web/protected/__init__.py b/shard_core/web/protected/__init__.py index 21cbdb2..f35f8a9 100644 --- a/shard_core/web/protected/__init__.py +++ b/shard_core/web/protected/__init__.py @@ -2,6 +2,7 @@ from . import ( apps, + authelia_users, backup, feedback, identities, @@ -20,6 +21,7 @@ ) router.include_router(apps.router) +router.include_router(authelia_users.router) router.include_router(backup.router) router.include_router(feedback.router) router.include_router(identities.router) diff --git a/shard_core/web/protected/authelia_users.py b/shard_core/web/protected/authelia_users.py new file mode 100644 index 0000000..9405ed1 --- /dev/null +++ b/shard_core/web/protected/authelia_users.py @@ -0,0 +1,98 @@ +import logging +from typing import List, Optional + +from fastapi import APIRouter, HTTPException, Response, status +from pydantic import BaseModel + +from shard_core.service import authelia + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/authelia/users") + + +class UserOutput(BaseModel): + username: str + display_name: str + email: str + groups: List[str] + + +class CreateUserInput(BaseModel): + username: str + display_name: str + email: str + password: str + groups: List[str] = [] + + +class UpdateUserInput(BaseModel): + display_name: Optional[str] = None + email: Optional[str] = None + password: Optional[str] = None + groups: Optional[List[str]] = None + + +@router.get("", response_model=List[UserOutput]) +def list_users(): + users = authelia.list_users() + return [ + UserOutput( + username=username, + display_name=data["displayname"], + email=data["email"], + groups=data.get("groups") or [], + ) + for username, data in users.items() + ] + + +@router.get("/{username}", response_model=UserOutput) +def get_user(username: str): + user = authelia.get_user(username) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return UserOutput( + username=username, + display_name=user["displayname"], + email=user["email"], + groups=user.get("groups") or [], + ) + + +@router.post("", status_code=status.HTTP_201_CREATED) +def create_user(body: CreateUserInput): + try: + authelia.create_user( + username=body.username, + display_name=body.display_name, + email=body.email, + password=body.password, + groups=body.groups, + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + + +@router.patch("/{username}", status_code=status.HTTP_200_OK) +def update_user(username: str, body: UpdateUserInput): + try: + authelia.update_user( + username=username, + display_name=body.display_name, + email=body.email, + password=body.password, + groups=body.groups, + ) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + +@router.delete( + "/{username}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response +) +def delete_user(username: str): + try: + authelia.delete_user(username) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) diff --git a/tests/test_authelia_users.py b/tests/test_authelia_users.py new file mode 100644 index 0000000..7f25186 --- /dev/null +++ b/tests/test_authelia_users.py @@ -0,0 +1,206 @@ +"""Tests for the Authelia user management API and service layer.""" + +import pytest +from pathlib import Path + +from shard_core.service import authelia +from shard_core.settings import settings + + +@pytest.fixture +def users_db_path(tmp_path, config_override): + return Path(settings().path_root) / "core" / "authelia" / "users_database.yml" + + +# ── service layer tests ────────────────────────────────────────────────────── + + +def test_hash_password_produces_argon2id_phc_string(): + hashed = authelia.hash_password("hunter2") + assert hashed.startswith("$argon2id$v=19$") + parts = hashed.split("$") + assert len(parts) == 6 + # parts: ['', 'argon2id', 'v=19', 'm=65536,t=3,p=4', salt_b64, hash_b64] + assert parts[3] == "m=65536,t=3,p=4" + + +def test_hash_password_different_salts(): + h1 = authelia.hash_password("same") + h2 = authelia.hash_password("same") + # Random salt means different hashes even for the same password + assert h1 != h2 + + +def test_list_users_empty_when_no_db(users_db_path): + assert authelia.list_users() == {} + + +def test_create_and_get_user(users_db_path): + authelia.create_user( + "alice", "Alice Smith", "alice@example.com", "secret", ["admins"] + ) + user = authelia.get_user("alice") + assert user is not None + assert user["displayname"] == "Alice Smith" + assert user["email"] == "alice@example.com" + assert user["groups"] == ["admins"] + assert user["password"].startswith("$argon2id$") + + +def test_list_users_returns_all(users_db_path): + authelia.create_user("alice", "Alice", "alice@example.com", "pw") + authelia.create_user("bob", "Bob", "bob@example.com", "pw") + users = authelia.list_users() + assert set(users.keys()) == {"alice", "bob"} + + +def test_create_duplicate_user_raises(users_db_path): + authelia.create_user("alice", "Alice", "alice@example.com", "pw") + with pytest.raises(ValueError, match="already exists"): + authelia.create_user("alice", "Alice 2", "alice2@example.com", "pw2") + + +def test_update_user_display_name(users_db_path): + authelia.create_user("alice", "Alice", "alice@example.com", "pw") + authelia.update_user("alice", display_name="Alicia") + user = authelia.get_user("alice") + assert user["displayname"] == "Alicia" + assert user["email"] == "alice@example.com" + + +def test_update_user_password(users_db_path): + authelia.create_user("alice", "Alice", "alice@example.com", "old") + old_hash = authelia.get_user("alice")["password"] + authelia.update_user("alice", password="new") + new_hash = authelia.get_user("alice")["password"] + assert old_hash != new_hash + assert new_hash.startswith("$argon2id$") + + +def test_update_user_groups(users_db_path): + authelia.create_user("alice", "Alice", "alice@example.com", "pw", ["users"]) + authelia.update_user("alice", groups=["admins", "users"]) + assert authelia.get_user("alice")["groups"] == ["admins", "users"] + + +def test_update_nonexistent_user_raises(users_db_path): + with pytest.raises(KeyError): + authelia.update_user("nobody", display_name="Ghost") + + +def test_delete_user(users_db_path): + authelia.create_user("alice", "Alice", "alice@example.com", "pw") + authelia.delete_user("alice") + assert authelia.get_user("alice") is None + assert authelia.list_users() == {} + + +def test_delete_nonexistent_user_raises(users_db_path): + with pytest.raises(KeyError): + authelia.delete_user("nobody") + + +def test_users_persisted_across_reads(users_db_path): + authelia.create_user("alice", "Alice", "alice@example.com", "pw") + # Read again via list to simulate a fresh load from disk + users = authelia.list_users() + assert "alice" in users + + +# ── API endpoint tests ──────────────────────────────────────────────────────── + + +async def test_api_list_users_empty(app_client): + # Ensure the users DB exists (normally written at startup, skip here) + authelia.get_users_db_path().parent.mkdir(parents=True, exist_ok=True) + authelia.get_users_db_path().write_text("users: {}\n") + + resp = await app_client.get("/protected/authelia/users") + assert resp.status_code == 200 + assert resp.json() == [] + + +async def test_api_create_and_get_user(app_client): + authelia.get_users_db_path().parent.mkdir(parents=True, exist_ok=True) + authelia.get_users_db_path().write_text("users: {}\n") + + payload = { + "username": "bob", + "display_name": "Bob Jones", + "email": "bob@example.com", + "password": "secret123", + "groups": ["users"], + } + resp = await app_client.post("/protected/authelia/users", json=payload) + assert resp.status_code == 201 + + resp = await app_client.get("/protected/authelia/users/bob") + assert resp.status_code == 200 + data = resp.json() + assert data["username"] == "bob" + assert data["display_name"] == "Bob Jones" + assert data["email"] == "bob@example.com" + assert data["groups"] == ["users"] + + +async def test_api_create_duplicate_returns_409(app_client): + authelia.get_users_db_path().parent.mkdir(parents=True, exist_ok=True) + authelia.get_users_db_path().write_text("users: {}\n") + + payload = { + "username": "bob", + "display_name": "Bob", + "email": "b@b.com", + "password": "pw", + } + await app_client.post("/protected/authelia/users", json=payload) + resp = await app_client.post("/protected/authelia/users", json=payload) + assert resp.status_code == 409 + + +async def test_api_get_nonexistent_returns_404(app_client): + authelia.get_users_db_path().parent.mkdir(parents=True, exist_ok=True) + authelia.get_users_db_path().write_text("users: {}\n") + + resp = await app_client.get("/protected/authelia/users/nobody") + assert resp.status_code == 404 + + +async def test_api_update_user(app_client): + authelia.get_users_db_path().parent.mkdir(parents=True, exist_ok=True) + authelia.get_users_db_path().write_text("users: {}\n") + + payload = { + "username": "carol", + "display_name": "Carol", + "email": "c@c.com", + "password": "pw", + } + await app_client.post("/protected/authelia/users", json=payload) + + resp = await app_client.patch( + "/protected/authelia/users/carol", json={"display_name": "Caroline"} + ) + assert resp.status_code == 200 + + data = (await app_client.get("/protected/authelia/users/carol")).json() + assert data["display_name"] == "Caroline" + + +async def test_api_delete_user(app_client): + authelia.get_users_db_path().parent.mkdir(parents=True, exist_ok=True) + authelia.get_users_db_path().write_text("users: {}\n") + + payload = { + "username": "dave", + "display_name": "Dave", + "email": "d@d.com", + "password": "pw", + } + await app_client.post("/protected/authelia/users", json=payload) + + resp = await app_client.delete("/protected/authelia/users/dave") + assert resp.status_code == 204 + + resp = await app_client.get("/protected/authelia/users/dave") + assert resp.status_code == 404 diff --git a/tests/test_call_peer.py b/tests/test_call_peer.py index fa62b2b..bea6adc 100644 --- a/tests/test_call_peer.py +++ b/tests/test_call_peer.py @@ -6,7 +6,11 @@ from requests_http_signature import HTTPSignatureAuth from shard_core.data_model.identity import OutputIdentity -from tests.util import verify_signature_auth, modify_request_like_traefik_forward_auth, install_app +from tests.util import ( + verify_signature_auth, + modify_request_like_traefik_forward_auth, + install_app, +) async def test_call_peer_from_app_basic(app_client, peer_mock_requests): diff --git a/tests/test_traefik_dyn_spec.py b/tests/test_traefik_dyn_spec.py index 349e8d1..bcadb8c 100644 --- a/tests/test_traefik_dyn_spec.py +++ b/tests/test_traefik_dyn_spec.py @@ -15,17 +15,20 @@ async def test_template_is_written(api_client): assert set(out_middlewares.keys()) == { "app-error", "auth", + "authelia-forwardauth", "strip", "auth-public", "auth-private", "auth-management", } assert "authResponseHeadersRegex" in out_middlewares["auth"]["forwardAuth"] + assert out_middlewares["authelia-forwardauth"]["forwardAuth"]["address"] == "http://authelia:9091/api/authz/forward-auth" out_services_http: dict = output["http"]["services"] assert set(out_services_http.keys()) == { "shard_core", "web-terminal", + "authelia", "filebrowser_http", "paperless-ngx_http", "immich_http", @@ -33,6 +36,7 @@ async def test_template_is_written(api_client): assert out_services_http["filebrowser_http"]["loadBalancer"]["servers"] == [ {"url": "http://filebrowser:80"} ] + assert out_services_http["authelia"]["loadBalancer"]["servers"] == [{"url": "http://authelia:9091/"}] out_routers_http: dict = output["http"]["routers"] assert set(out_routers_http.keys()) == { @@ -41,8 +45,13 @@ async def test_template_is_written(api_client): "shard_core_management", "web-terminal", "traefik", + "authelia", "filebrowser_http", "paperless-ngx_http", "immich_http", } assert out_routers_http["filebrowser_http"]["service"] == "filebrowser_http" + assert out_routers_http["filebrowser_http"]["middlewares"] == [ + "app-error", + "authelia-forwardauth", + ]