From 58c6119e9e90b1026e20a7160de74eb1fe6e24c9 Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 29 Oct 2026 15:45:12 +0100 Subject: [PATCH 1/5] Fix OIDC redirects for split-origin deployments --- backend/app/config.py | 2 +- backend/app/routers/oidc.py | 23 +++++++++++++++++++---- backend/tests/conftest.py | 3 +++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index ea95b4ed..e1d434ad 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -62,7 +62,7 @@ class Settings(BaseSettings): mail_starttls: bool = True mail_ssl_tls: bool = False password_reset_token_max_age: int = 3600 - public_app_url: str = "http://localhost:5173" + public_app_url: str = "" forwarded_allow_ips: str = "*" telemetry_disabled: bool = False telemetry_endpoint: str = "https://metrics.librislog.app/api/telemetry" diff --git a/backend/app/routers/oidc.py b/backend/app/routers/oidc.py index 0e4fc0c9..728b4e3f 100644 --- a/backend/app/routers/oidc.py +++ b/backend/app/routers/oidc.py @@ -31,24 +31,39 @@ def _provider_id() -> str: return settings.oidc_provider_id +def _frontend_base_url() -> str: + """Base URL of the frontend app, used for browser redirects. + + Empty in single-origin (production) deployments, where the backend serves + the frontend and relative redirects resolve correctly. Set + ``PUBLIC_APP_URL`` in split-origin (local dev) setups. + """ + return (settings.public_app_url or "").rstrip("/") + + +def _with_frontend_base(path: str) -> str: + base = _frontend_base_url() + return f"{base}{path}" if base else path + + def _frontend_warning_redirect(message: str) -> RedirectResponse: """Redirect to the frontend login page with an OIDC error message.""" - return RedirectResponse(url=f"/login?oidc_error={quote_plus(message)}", status_code=302) + return RedirectResponse(url=_with_frontend_base(f"/login?oidc_error={quote_plus(message)}"), status_code=302) def _frontend_success_redirect() -> RedirectResponse: """Redirect to the frontend callback page on successful login.""" - return RedirectResponse(url="/auth/oidc/callback", status_code=302) + return RedirectResponse(url=_with_frontend_base("/auth/oidc/callback"), status_code=302) def _link_success_redirect() -> RedirectResponse: """Redirect to the frontend link callback with success status.""" - return RedirectResponse(url="/auth/oidc/link-callback?status=success", status_code=302) + return RedirectResponse(url=_with_frontend_base("/auth/oidc/link-callback?status=success"), status_code=302) def _link_error_redirect(message: str) -> RedirectResponse: """Redirect to the frontend link callback with an error message.""" - return RedirectResponse(url=f"/auth/oidc/link-callback?error={quote_plus(message)}", status_code=302) + return RedirectResponse(url=_with_frontend_base(f"/auth/oidc/link-callback?error={quote_plus(message)}"), status_code=302) def _resolve_callback_redirect_uri(request: Request, route_name: str) -> str: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2852bdbb..692069b4 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,9 @@ from sqlmodel.pool import StaticPool os.environ.setdefault("API_KEY_ENCRYPTION_KEY", "test-api-key-encryption-secret") +# OIDC frontend redirects stay relative in tests (single-origin assumption); +# local dev sets PUBLIC_APP_URL in .env for split-origin setups. +os.environ.setdefault("PUBLIC_APP_URL", "") # Telemetry must never fire during the test suite (no network, no dev-DB writes). os.environ["TELEMETRY_DISABLED"] = "true" From 6db74153612d3825f26d1ad2a658c5d7aa877ee1 Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 29 Oct 2026 20:07:20 +0100 Subject: [PATCH 2/5] Add configurable password login and trusted proxy authentication. --- backend/app/config.py | 5 + backend/app/database.py | 10 + backend/app/main.py | 136 ++++++++++-- backend/app/proxy_auth.py | 156 +++++++++++++ backend/app/routers/auth.py | 12 + backend/app/routers/config.py | 4 + backend/app/schemas.py | 2 + backend/tests/conftest.py | 4 +- backend/tests/test_auth_methods.py | 345 +++++++++++++++++++++++++++++ backend/tests/test_config.py | 26 ++- backend/tests/test_main.py | 5 +- 11 files changed, 682 insertions(+), 23 deletions(-) create mode 100644 backend/app/proxy_auth.py create mode 100644 backend/tests/test_auth_methods.py diff --git a/backend/app/config.py b/backend/app/config.py index e1d434ad..361de493 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,6 +19,11 @@ class Settings(BaseSettings): auth_cookie_secure: bool = False auth_cookie_samesite: str = "lax" auth_cookie_domain: str = "" + password_login_enabled: bool = True + trusted_proxy_auth_enabled: bool = False + trusted_proxy_auth_header: str = "X-Forwarded-User" + trusted_proxy_auth_ips: str = "" + trusted_proxy_auth_secret: str = "" oidc_enabled: bool = False oidc_provider_id: str = "oidc" oidc_provider_name: str = "Single Sign-On" diff --git a/backend/app/database.py b/backend/app/database.py index 3c9bae5d..bdf1aaca 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -45,3 +45,13 @@ def get_session() -> Generator[Session, None, None]: """Yield a SQLModel Session for the lifespan of the request.""" with Session(engine) as session: yield session + + +def create_session() -> Session: + """Create a standalone session for code that runs outside request dependencies. + + Used by the trusted-proxy authentication middleware, which cannot use FastAPI's + dependency injection. The returned session is a context manager; callers must + close it, for example ``with create_session() as session:``. + """ + return Session(engine) diff --git a/backend/app/main.py b/backend/app/main.py index a94522bd..a7530541 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,15 +2,19 @@ import asyncio import logging +import secrets from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from starlette.concurrency import run_in_threadpool from starlette.middleware.sessions import SessionMiddleware from starlette.responses import Response +from app import proxy_auth from app._build_info import __git_sha__, __version__ +from app.auth import start_browser_session from app.config import settings from app.logging_config import configure_logging from app.routers import admin, auth, books, config, cover_candidates, covers, data, docs, embed, health, hygiene, import_, oidc, profile, progress, public_profile, sessions, share_links, statistics, tags, users @@ -159,26 +163,117 @@ async def lifespan(app: FastAPI): redoc_url=None, ) -app.add_middleware( - CORSMiddleware, - allow_origins=settings.cors_origins, - allow_methods=["*"], - allow_headers=["*"], - allow_credentials=True, -) - - def _clean_env_value(value: str) -> str: """Strip inline comments and whitespace from an env-var string.""" return value.split("#", 1)[0].strip() +def _parse_forwarded_allow_ips(value: str) -> set[str]: + """Parse a comma/space-separated IP allow list.""" + if value.strip() == "*": + return {"*"} + return {ip.strip() for ip in value.replace(",", " ").split() if ip.strip()} + + cookie_domain_raw: str = _clean_env_value(settings.auth_cookie_domain) cookie_domain: str | None = cookie_domain_raw or None cookie_samesite: str = _clean_env_value(settings.auth_cookie_samesite).lower() if cookie_samesite not in {"lax", "strict", "none"}: cookie_samesite = "lax" cookie_name: str = _clean_env_value(settings.auth_cookie_name) or "librislog_session" + +_proxy_auth_ips = proxy_auth.trusted_ips() +_proxy_auth_header = proxy_auth.header_name() + +if settings.trusted_proxy_auth_enabled: + if not _proxy_auth_ips: + logger.warning( + "TRUSTED_PROXY_AUTH_ENABLED is true but TRUSTED_PROXY_AUTH_IPS is empty; " + "trusted proxy authentication is disabled" + ) + elif not _proxy_auth_header: + logger.warning( + "TRUSTED_PROXY_AUTH_ENABLED is true but TRUSTED_PROXY_AUTH_HEADER is empty; " + "trusted proxy authentication is disabled" + ) + elif "*" in _proxy_auth_ips: + logger.warning( + "TRUSTED_PROXY_AUTH_IPS contains '*'; trusted proxy authentication trusts any " + "client and is unsafe outside an isolated network" + ) + + if not proxy_auth.secret(): + logger.warning( + "TRUSTED_PROXY_AUTH_SECRET is not set; anyone who can reach the backend directly " + "and spoof the identity header could impersonate a user" + ) + if "*" in _parse_forwarded_allow_ips(settings.forwarded_allow_ips): + logger.warning( + "FORWARDED_ALLOW_IPS contains '*'; uvicorn may rewrite request.client from " + "X-Forwarded-For, so the trusted proxy IP check can be bypassed" + ) + + +# This middleware must be registered before SessionMiddleware so that +# SessionMiddleware wraps it and request.session is available inside. +@app.middleware("http") +async def trusted_proxy_auth_middleware(request: Request, call_next) -> Response: + """Auto-authenticate a user forwarded by a trusted reverse proxy.""" + if not settings.trusted_proxy_auth_enabled: + return await call_next(request) + + allow_ips = proxy_auth.trusted_ips() + configured_header = proxy_auth.header_name() + if not allow_ips or not configured_header: + return await call_next(request) + if request.method == "OPTIONS": + return await call_next(request) + + client = request.client + if client is None or not proxy_auth.ip_is_trusted(client.host, allow_ips): + return await call_next(request) + + required_secret = proxy_auth.secret() + if required_secret: + provided_secret = request.headers.get(proxy_auth.SECRET_HEADER_NAME, "") + if not secrets.compare_digest(provided_secret, required_secret): + return await call_next(request) + + forwarded_user = request.headers.get(configured_header, "").strip() + if not forwarded_user: + return await call_next(request) + + claimed_email = forwarded_user.lower() + if ( + request.session.get("user_id") is not None + and request.session.get("proxy_auth_email") == claimed_email + ): + return await call_next(request) + + try: + user = await run_in_threadpool(proxy_auth.resolve_user, forwarded_user) + except Exception: # noqa: BLE001 — proxy auth must fail closed, never break the request + logger.warning("Trusted proxy user lookup failed", exc_info=True) + return await call_next(request) + + if user is None: + logger.debug("Trusted proxy header did not match exactly one existing user") + return await call_next(request) + + existing_id = request.session.get("user_id") + if existing_id == user.id: + request.session["proxy_auth_email"] = claimed_email + return await call_next(request) + if existing_id is not None: + # Remove only authentication keys so an in-flight OIDC state survives. + for key in ("user_id", "csrf_token", "credentials_version"): + request.session.pop(key, None) + start_browser_session(request, user.id, user.credentials_version) + request.session["proxy_auth_email"] = claimed_email + logger.debug("Authenticated user %s via trusted proxy header", user.id) + return await call_next(request) + + app.add_middleware( SessionMiddleware, secret_key=settings.api_key_encryption_key, @@ -188,14 +283,8 @@ def _clean_env_value(value: str) -> str: domain=cookie_domain, ) -def _parse_forwarded_allow_ips(value: str) -> set[str]: - """Parse the comma/space-separated ``forwarded_allow_ips`` setting.""" - if value.strip() == "*": - return {"*"} - return {ip.strip() for ip in value.replace(",", " ").split() if ip.strip()} - -_TRUSTED_PROXY_IPS = _parse_forwarded_allow_ips(settings.forwarded_allow_ips) +_FORWARDED_PROTO_TRUSTED_IPS = _parse_forwarded_allow_ips(settings.forwarded_allow_ips) @app.middleware("http") @@ -207,7 +296,9 @@ async def proxy_headers_middleware(request: Request, call_next) -> Response: That breaks OIDC flows because Authlib validates the redirect URI against ``request.url``. """ - if "*" in _TRUSTED_PROXY_IPS or (request.client and request.client.host in _TRUSTED_PROXY_IPS): + if "*" in _FORWARDED_PROTO_TRUSTED_IPS or ( + request.client and request.client.host in _FORWARDED_PROTO_TRUSTED_IPS + ): forwarded_proto = request.headers.get("x-forwarded-proto") if forwarded_proto: logger.debug("X-Forwarded-Proto=%s — patching scheme to %s", forwarded_proto, forwarded_proto) @@ -219,6 +310,17 @@ async def proxy_headers_middleware(request: Request, call_next) -> Response: return await call_next(request) +# Register CORS last so it is the outermost middleware and can add headers to +# every response, including requests handled by the proxy-auth middleware. +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True, +) + + app.include_router(books.router) app.include_router(sessions.router) app.include_router(import_.router) diff --git a/backend/app/proxy_auth.py b/backend/app/proxy_auth.py new file mode 100644 index 00000000..c9037119 --- /dev/null +++ b/backend/app/proxy_auth.py @@ -0,0 +1,156 @@ +"""Trusted reverse-proxy authentication helpers. + +Kept separate from :mod:`app.main` so the public config endpoint can report the +effective (fail-closed) state without importing the application module. +""" + +import ipaddress +import logging +from collections.abc import Callable +from functools import lru_cache +from typing import NamedTuple + +from sqlalchemy import func +from sqlmodel import Session, select + +from app.config import settings +from app.database import create_session +from app.models import User + +logger = logging.getLogger(__name__) + +#: Header a proxy can set in addition to the identity header. Optional, but +#: recommended whenever the backend might be reachable directly. +SECRET_HEADER_NAME = "X-Trusted-Proxy-Secret" + + +class ProxyUser(NamedTuple): + """The minimal user data proxy authentication needs.""" + + id: int + credentials_version: int + + +def _clean(value: str) -> str: + """Strip inline comments and surrounding whitespace from an env value.""" + return value.split("#", 1)[0].strip() + + +def parse_ip_allow_list(value: str) -> set[str]: + """Parse a comma/space-separated list of IP addresses and CIDR networks. + + ``*`` trusts any client. Invalid entries are skipped with a warning so a + typo cannot silently widen or break the allow list. + """ + raw = _clean(value) + if not raw: + return set() + + entries: set[str] = set() + for item in raw.replace(",", " ").split(): + item = item.strip() + if not item: + continue + if item == "*": + entries.add("*") + continue + try: + if "/" in item: + entries.add(str(ipaddress.ip_network(item, strict=False))) + else: + address = ipaddress.ip_address(item) + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped: + address = address.ipv4_mapped + entries.add(str(address)) + except ValueError: + logger.warning("Ignoring invalid trusted proxy IP entry %r", item) + return entries + + +@lru_cache(maxsize=16) +def _cached_ip_allow_list(value: str) -> frozenset[str]: + """Parse the allow list once per distinct configuration value.""" + return frozenset(parse_ip_allow_list(value)) + + +def trusted_ips() -> frozenset[str]: + """Return the configured allow list, parsed once per distinct value.""" + return _cached_ip_allow_list(settings.trusted_proxy_auth_ips) + + +def ip_is_trusted(host: str | None, allow_list: set[str] | frozenset[str]) -> bool: + """Return True when *host* matches the parsed allow list.""" + if not host: + return False + if "*" in allow_list: + return True + + try: + address = ipaddress.ip_address(host) + except ValueError: + return False + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped: + address = address.ipv4_mapped + + for entry in allow_list: + if entry == "*": + return True + try: + if "/" in entry: + if address in ipaddress.ip_network(entry): + return True + elif address == ipaddress.ip_address(entry): + return True + except ValueError: + continue + return False + + +def header_name() -> str: + """Return the configured identity header name.""" + return _clean(settings.trusted_proxy_auth_header) + + +def secret() -> str: + """Return the optional shared secret. + + Do not strip inline ``#`` comments here: a secret may legitimately contain + ``#``, and truncating it would silently lock the proxy out. + """ + return settings.trusted_proxy_auth_secret.strip() + + +def is_active() -> bool: + """Return True only when the feature is enabled and safely configured.""" + if not settings.trusted_proxy_auth_enabled: + return False + return bool(trusted_ips() and header_name()) + + +_session_factory: Callable[[], Session] = create_session + + +def resolve_user(email: str) -> ProxyUser | None: + """Find exactly one existing user by a case-insensitive email match. + + Returns ``None`` when no user matches, when the address is ambiguous, or + when the lookup fails. The caller must treat every non-match as anonymous + access rather than as an error. + """ + key = email.strip().lower() + if not key: + return None + + with _session_factory() as session: + rows = session.exec( + select(User.id, User.credentials_version).where(func.lower(User.email) == key) + ).all() + + if len(rows) > 1: + logger.warning( + "Trusted proxy email matched %d users; refusing to authenticate", len(rows) + ) + return None + if not rows: + return None + return ProxyUser(rows[0][0], rows[0][1]) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 75071e9f..a5129e3c 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -29,6 +29,15 @@ router = APIRouter(prefix="/api/auth", tags=["auth"]) +def _ensure_password_login_enabled() -> None: + """Reject password-based flows when the operator disabled them.""" + if not settings.password_login_enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Password login is disabled", + ) + + @router.get("/setup-required") def setup_required(session: Session = Depends(get_session)) -> dict: """Return whether initial admin setup is required.""" @@ -79,6 +88,7 @@ def login( session: Session = Depends(get_session), ) -> dict: """Authenticate with email and password, starting a browser session.""" + _ensure_password_login_enabled() user = session.exec(select(User).where(User.email == credentials.email)).first() if not user or not verify_password(credentials.password, user.hashed_password): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password") @@ -120,6 +130,7 @@ async def forgot_password( Always returns 200 to prevent user enumeration. """ + _ensure_password_login_enabled() user = session.exec(select(User).where(User.email == body.email)).first() if user and settings.mail_server: token = generate_password_reset_token(body.email, user.credentials_version) @@ -134,6 +145,7 @@ def reset_password( session: Session = Depends(get_session), ) -> dict: """Reset a user's password using a valid reset token.""" + _ensure_password_login_enabled() payload = verify_password_reset_token(body.token, max_age=settings.password_reset_token_max_age) if not payload: raise HTTPException( diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index ca6aae93..015aacf4 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -2,6 +2,7 @@ from fastapi import APIRouter +from app import proxy_auth from app.config import settings from app.schemas import AppConfigRead @@ -15,4 +16,7 @@ def app_config() -> AppConfigRead: embed_enabled=settings.embed_enabled, dashboard_quote_enabled=settings.dashboard_quote_enabled, thalia_cover_search_enabled=settings.thalia_cover_search_enabled, + password_login_enabled=settings.password_login_enabled, + # Report the effective state: a misconfigured proxy feature is inactive. + trusted_proxy_auth_enabled=proxy_auth.is_active(), ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index c5ba63b0..064e5bd6 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -665,6 +665,8 @@ class AppConfigRead(SQLModel): embed_enabled: bool dashboard_quote_enabled: bool thalia_cover_search_enabled: bool + password_login_enabled: bool + trusted_proxy_auth_enabled: bool class OidcLinkRead(SQLModel): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 692069b4..83d76e2e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -70,7 +70,9 @@ def override_get_session() -> Generator[Session, None, None]: yield session app.dependency_overrides[get_session] = override_get_session - with TestClient(app) as client: + # Use a real loopback address so middleware that inspects request.client + # can be configured with ordinary IP allow lists. + with TestClient(app, client=("127.0.0.1", 50000)) as client: client.headers.update({"X-API-Key": key_plain}) yield client app.dependency_overrides.clear() diff --git a/backend/tests/test_auth_methods.py b/backend/tests/test_auth_methods.py new file mode 100644 index 00000000..6c2f5613 --- /dev/null +++ b/backend/tests/test_auth_methods.py @@ -0,0 +1,345 @@ +"""Tests for configurable password login and trusted reverse-proxy authentication.""" + +from collections.abc import Generator + +import pytest +from fastapi import Request +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app import proxy_auth +from app.auth import get_password_hash +from app.database import get_session +from app.main import app +from app.models import User, UserRole + + +@pytest.fixture(name="password_login_disabled") +def password_login_disabled_fixture(monkeypatch) -> None: + """Disable password-based flows for the duration of a test.""" + monkeypatch.setattr("app.config.settings.password_login_enabled", False) + + +def test_login_rejected_when_password_login_disabled( + client: TestClient, password_login_disabled: None +) -> None: + resp = client.post( + "/api/auth/login", + json={"email": "test@example.com", "password": "test-password"}, + ) + assert resp.status_code == 403 + assert resp.json()["detail"] == "Password login is disabled" + + +def test_forgot_password_rejected_when_password_login_disabled( + client: TestClient, password_login_disabled: None +) -> None: + resp = client.post("/api/auth/forgot-password", json={"email": "test@example.com"}) + assert resp.status_code == 403 + assert resp.json()["detail"] == "Password login is disabled" + + +def test_reset_password_rejected_when_password_login_disabled( + client: TestClient, password_login_disabled: None +) -> None: + resp = client.post( + "/api/auth/reset-password", + json={"token": "irrelevant", "password": "Secret123!"}, + ) + assert resp.status_code == 403 + assert resp.json()["detail"] == "Password login is disabled" + + +def test_setup_still_bootstraps_when_password_login_disabled( + session: Session, password_login_disabled: None +) -> None: + def override_get_session() -> Generator[Session, None, None]: + yield session + + app.dependency_overrides[get_session] = override_get_session + try: + with TestClient(app) as raw_client: + assert raw_client.get("/api/auth/setup-required").json() == {"required": True} + setup = raw_client.post( + "/api/auth/setup", + json={ + "firstname": "First", + "lastname": "Admin", + "email": "first-admin@example.com", + "password": "Secret123!", + }, + ) + assert setup.status_code == 200 + assert setup.json()["user"]["role"] == "admin" + + me = raw_client.get("/api/auth/me") + assert me.status_code == 200 + assert me.json()["email"] == "first-admin@example.com" + finally: + app.dependency_overrides.clear() + + +# ── Trusted reverse-proxy authentication ────────────────────────────────────── + + +@pytest.fixture(name="proxy_auth_enabled") +def proxy_auth_enabled_fixture(monkeypatch, session: Session) -> Session: + """Enable trusted proxy auth and point the resolver at the in-memory DB.""" + import app.main as main_module + + engine = session.get_bind() + monkeypatch.setattr(proxy_auth, "_session_factory", lambda: Session(engine)) + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_enabled", True) + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_ips", "127.0.0.1") + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_header", "X-Forwarded-User") + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_secret", "") + return session + + +def _add_user( + session: Session, + *, + email: str, + firstname: str = "Proxy", + role: UserRole = UserRole.user, +) -> User: + user = User( + firstname=firstname, + lastname="User", + email=email, + role=role, + hashed_password=get_password_hash("proxy-password"), + ) + session.add(user) + session.commit() + session.refresh(user) + return user + + +def test_proxy_auth_creates_session_from_trusted_header( + client: TestClient, proxy_auth_enabled: Session +) -> None: + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert resp.status_code == 200 + assert resp.json()["email"] == "test@example.com" + assert "set-cookie" in {key.lower() for key in resp.headers} + + +def test_proxy_auth_ignores_untrusted_client( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_ips", "10.0.0.1") + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert resp.status_code == 401 + + +def test_proxy_auth_ignores_unknown_email( + client: TestClient, proxy_auth_enabled: Session +) -> None: + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "nobody@example.com"}) + assert resp.status_code == 401 + + +def test_proxy_auth_disabled_when_ip_list_empty( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_ips", "") + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert resp.status_code == 401 + + config = client.get("/api/config").json() + assert config["trusted_proxy_auth_enabled"] is False + + +def test_proxy_auth_accepts_wildcard_ip( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_ips", "*") + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert resp.status_code == 200 + + +def test_proxy_auth_requires_configured_secret( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_secret", "s3cret") + client.headers.pop("X-API-Key", None) + + missing = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert missing.status_code == 401 + + wrong = client.get( + "/api/auth/me", + headers={"X-Forwarded-User": "test@example.com", proxy_auth.SECRET_HEADER_NAME: "nope"}, + ) + assert wrong.status_code == 401 + + correct = client.get( + "/api/auth/me", + headers={"X-Forwarded-User": "test@example.com", proxy_auth.SECRET_HEADER_NAME: "s3cret"}, + ) + assert correct.status_code == 200 + assert correct.json()["email"] == "test@example.com" + + +def test_proxy_auth_fails_closed_when_user_lookup_raises( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + def broken_factory(): + raise RuntimeError("database unavailable") + + monkeypatch.setattr(proxy_auth, "_session_factory", broken_factory) + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert resp.status_code == 401 + + +def test_proxy_auth_skips_lookup_for_same_session_user( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + calls = {"count": 0} + original = proxy_auth.resolve_user + + def counting_resolver(email: str): + calls["count"] += 1 + return original(email) + + monkeypatch.setattr(proxy_auth, "resolve_user", counting_resolver) + client.headers.pop("X-API-Key", None) + + first = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + second = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert first.status_code == 200 + assert second.status_code == 200 + assert calls["count"] == 1 + + +def test_proxy_auth_reauthenticates_when_user_changes( + client: TestClient, proxy_auth_enabled: Session +) -> None: + second = _add_user(proxy_auth_enabled, email="second@example.com", firstname="Second") + client.headers.pop("X-API-Key", None) + + first = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert first.status_code == 200 + assert first.json()["email"] == "test@example.com" + + second_resp = client.get("/api/auth/me", headers={"X-Forwarded-User": second.email}) + assert second_resp.status_code == 200 + assert second_resp.json()["email"] == "second@example.com" + + +@pytest.mark.anyio +async def test_proxy_auth_middleware_skips_options_and_missing_client(monkeypatch) -> None: + import app.main as main_module + + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_enabled", True) + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_ips", "10.0.0.1") + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_header", "X-Forwarded-User") + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_secret", "") + + called = {"count": 0} + + async def call_next(request: Request): + called["count"] += 1 + return None + + options_scope = { + "type": "http", + "method": "OPTIONS", + "path": "/", + "headers": [(b"x-forwarded-user", b"test@example.com")], + "client": ("10.0.0.1", 1234), + "session": {}, + } + await main_module.trusted_proxy_auth_middleware(Request(options_scope), call_next) + assert called["count"] == 1 + + no_client_scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [(b"x-forwarded-user", b"test@example.com")], + "client": None, + "session": {}, + } + await main_module.trusted_proxy_auth_middleware(Request(no_client_scope), call_next) + assert called["count"] == 2 + + +def test_proxy_auth_is_inactive_by_default(client: TestClient) -> None: + """Default configuration must not auto-authenticate anyone.""" + client.headers.pop("X-API-Key", None) + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert resp.status_code == 401 + + +def test_proxy_auth_lookup_is_case_insensitive( + client: TestClient, proxy_auth_enabled: Session +) -> None: + from sqlmodel import select + + expected = proxy_auth_enabled.exec( + select(User).where(User.email == "test@example.com") + ).first() + assert expected is not None + + user = proxy_auth.resolve_user("TEST@EXAMPLE.COM") + assert user is not None + assert user.id == expected.id + + +def test_proxy_auth_lookup_rejects_ambiguous_case_variants( + client: TestClient, proxy_auth_enabled: Session +) -> None: + _add_user(proxy_auth_enabled, email="TEST@example.com", firstname="Variant") + + assert proxy_auth.resolve_user("test@example.com") is None + + +def test_proxy_auth_secret_preserves_hash_characters(monkeypatch) -> None: + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_secret", "ab#cd") + assert proxy_auth.secret() == "ab#cd" + + +def test_parse_ip_allow_list_supports_cidr_and_ipv6() -> None: + entries = proxy_auth.parse_ip_allow_list( + "172.18.0.0/16, 2001:db8::1, ::ffff:192.168.1.5, not-an-ip" + ) + + assert "172.18.0.0/16" in entries + assert "2001:db8::1" in entries + assert "192.168.1.5" in entries + assert proxy_auth.ip_is_trusted("172.18.5.4", entries) is True + assert proxy_auth.ip_is_trusted("10.0.0.1", entries) is False + assert proxy_auth.ip_is_trusted(None, entries) is False + + +def test_proxy_auth_middleware_is_inside_session_middleware() -> None: + names = [] + for middleware in app.user_middleware: + cls_name = getattr(middleware.cls, "__name__", str(middleware.cls)) + dispatch = middleware.kwargs.get("dispatch") + names.append(getattr(dispatch, "__name__", cls_name) if dispatch else cls_name) + + # The list is outermost-first, so SessionMiddleware must appear earlier. + assert names.index("SessionMiddleware") < names.index("trusted_proxy_auth_middleware") diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index 41b3ac86..7b852c6c 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -1,7 +1,5 @@ """Tests for app configuration validation and config endpoint.""" -from typing import Any - import pytest from fastapi.testclient import TestClient @@ -28,6 +26,10 @@ def test_get_config_returns_feature_flags(client: TestClient, monkeypatch) -> No monkeypatch.setattr("app.config.settings.embed_enabled", True) monkeypatch.setattr("app.config.settings.dashboard_quote_enabled", False) monkeypatch.setattr("app.config.settings.thalia_cover_search_enabled", True) + monkeypatch.setattr("app.config.settings.password_login_enabled", False) + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_enabled", True) + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_ips", "127.0.0.1") + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_header", "X-Forwarded-User") resp = client.get("/api/config") assert resp.status_code == 200 @@ -36,4 +38,24 @@ def test_get_config_returns_feature_flags(client: TestClient, monkeypatch) -> No "embed_enabled": True, "dashboard_quote_enabled": False, "thalia_cover_search_enabled": True, + "password_login_enabled": False, + "trusted_proxy_auth_enabled": True, } + assert "trusted_proxy_auth_header" not in data + + +def test_proxy_auth_flag_is_false_when_misconfigured(client: TestClient, monkeypatch) -> None: + """An enabled but fail-closed feature must not be advertised as usable.""" + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_enabled", True) + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_ips", "") + + assert client.get("/api/config").json()["trusted_proxy_auth_enabled"] is False + + +def test_proxy_auth_flag_is_false_with_empty_header(client: TestClient, monkeypatch) -> None: + """An empty header name is the other fail-closed configuration.""" + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_enabled", True) + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_ips", "127.0.0.1") + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_header", "") + + assert client.get("/api/config").json()["trusted_proxy_auth_enabled"] is False diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 8225eda1..26db833d 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -6,7 +6,6 @@ import pytest from fastapi import FastAPI, Request -from fastapi.testclient import TestClient from starlette.responses import JSONResponse, Response @@ -162,7 +161,7 @@ async def call_next(request: Request) -> Response: return JSONResponse(received) request = Request(scope) - response = await proxy_headers_middleware(request, call_next) + await proxy_headers_middleware(request, call_next) assert received is not None assert received["scheme"] == "https" @@ -353,7 +352,7 @@ async def test_proxy_headers_middleware_skips_untrusted_proxy(monkeypatch) -> No """When request is not from a trusted proxy, X-Forwarded-Proto is ignored.""" from app.main import proxy_headers_middleware - monkeypatch.setattr("app.main._TRUSTED_PROXY_IPS", {"10.0.0.5"}) + monkeypatch.setattr("app.main._FORWARDED_PROTO_TRUSTED_IPS", {"10.0.0.5"}) scope = { "type": "http", From 3b631bdcf12da188d1b33b44b6fb12f950996a62 Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 29 Oct 2026 20:07:30 +0100 Subject: [PATCH 3/5] Adapt the authentication pages to the new login options. --- frontend/e2e/specs/11-profile.spec.ts | 4 +- frontend/src/lib/i18n/locales/de.json | 9 +- frontend/src/lib/i18n/locales/en.json | 9 +- frontend/src/lib/i18n/locales/es.json | 9 +- frontend/src/lib/i18n/locales/fr.json | 9 +- frontend/src/lib/i18n/locales/zh.json | 9 +- frontend/src/lib/types.ts | 2 + frontend/src/routes/login/+page.svelte | 118 ++++++++++++------ frontend/src/routes/login/page.test.ts | 107 ++++++++++++++++ frontend/src/routes/profile/+page.svelte | 36 +++--- .../src/routes/reset-password/+page.svelte | 24 +++- frontend/src/routes/setup/+page.svelte | 41 +++++- frontend/src/routes/setup/page.test.ts | 83 ++++++++++++ 13 files changed, 394 insertions(+), 66 deletions(-) create mode 100644 frontend/src/routes/login/page.test.ts create mode 100644 frontend/src/routes/setup/page.test.ts diff --git a/frontend/e2e/specs/11-profile.spec.ts b/frontend/e2e/specs/11-profile.spec.ts index a810b5b7..b7586e68 100644 --- a/frontend/e2e/specs/11-profile.spec.ts +++ b/frontend/e2e/specs/11-profile.spec.ts @@ -93,7 +93,9 @@ test.describe('Profile', () => { body: JSON.stringify({ embed_enabled: false, dashboard_quote_enabled: true, - thalia_cover_search_enabled: false + thalia_cover_search_enabled: false, + password_login_enabled: true, + trusted_proxy_auth_enabled: false }) }); }); diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 8a0d5a0a..22a22212 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -422,7 +422,14 @@ "resetPasswordTitle": "Neues Passwort festlegen", "resetPasswordInstruction": "Gib dein neues Passwort ein.", "resetPasswordSuccess": "Passwort wurde erfolgreich zurückgesetzt.", - "resetPasswordSubmit": "Passwort zurücksetzen" + "resetPasswordSubmit": "Passwort zurücksetzen", + "passwordLoginDisabledHint": "Die Passwort-Anmeldung ist deaktiviert. Verwende Single Sign-on oder wende dich an deinen Administrator, wenn du dich nicht anmelden kannst.", + "proxyManagedHint": "Der Zugriff auf diese Instanz wird von deinem Reverse-Proxy verwaltet.", + "noLoginMethodAvailable": "Es ist derzeit keine Anmeldemethode konfiguriert. Wende dich an deinen Administrator.", + "configLoadFailed": "Die Anmeldekonfiguration konnte nicht geladen werden. Lade die Seite neu oder wende dich an deinen Administrator.", + "setupLinkOidcWarning": "Die Passwort-Anmeldung ist deaktiviert. Verknüpfe dein Single-Sign-on-Konto im Profil, bevor du dich abmeldest, sonst kannst du dich nicht erneut anmelden.", + "setupPasswordDisabledNoOidc": "Die Passwort-Anmeldung ist deaktiviert und kein Single-Sign-on-Anbieter konfiguriert. Aktiviere OIDC oder die Passwort-Anmeldung, bevor du dich abmeldest.", + "setupConfigCheckFailed": "Die Einstellung für die Passwort-Anmeldung konnte nicht geprüft werden. Falls sie deaktiviert ist, verknüpfe dein Single-Sign-on-Konto im Profil, bevor du dich abmeldest." }, "user": { "menu": "Benutzermenü", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index da559412..49adf340 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -422,7 +422,14 @@ "resetPasswordTitle": "Set New Password", "resetPasswordInstruction": "Enter your new password.", "resetPasswordSuccess": "Password has been reset successfully.", - "resetPasswordSubmit": "Reset Password" + "resetPasswordSubmit": "Reset Password", + "passwordLoginDisabledHint": "Password login is disabled. Use single sign-on, or contact your administrator if you cannot sign in.", + "proxyManagedHint": "Access to this instance is managed by your reverse proxy.", + "noLoginMethodAvailable": "No sign-in method is currently configured. Contact your administrator.", + "configLoadFailed": "Could not load the sign-in configuration. Reload the page or contact your administrator.", + "setupLinkOidcWarning": "Password login is disabled. Link your single sign-on account in Profile before you log out, or you will not be able to sign in again.", + "setupPasswordDisabledNoOidc": "Password login is disabled and no single sign-on provider is configured. Enable OIDC or re-enable password login before logging out.", + "setupConfigCheckFailed": "Could not verify the password login setting. If password login is disabled, link single sign-on in Profile before you log out." }, "user": { "menu": "User menu", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index ada389f1..2b5c5c0a 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -422,7 +422,14 @@ "resetPasswordTitle": "Establecer nueva contraseña", "resetPasswordInstruction": "Introduce tu nueva contraseña.", "resetPasswordSuccess": "Contraseña restablecida correctamente.", - "resetPasswordSubmit": "Restablecer contraseña" + "resetPasswordSubmit": "Restablecer contraseña", + "passwordLoginDisabledHint": "El inicio de sesión con contraseña está desactivado. Usa el inicio de sesión único o contacta con tu administrador si no puedes acceder.", + "proxyManagedHint": "El acceso a esta instancia lo gestiona tu proxy inverso.", + "noLoginMethodAvailable": "No hay ningún método de inicio de sesión configurado. Contacta con tu administrador.", + "configLoadFailed": "No se pudo cargar la configuración de inicio de sesión. Recarga la página o contacta con tu administrador.", + "setupLinkOidcWarning": "El inicio de sesión con contraseña está desactivado. Vincula tu cuenta de inicio de sesión único en el perfil antes de cerrar sesión, o no podrás volver a acceder.", + "setupPasswordDisabledNoOidc": "El inicio de sesión con contraseña está desactivado y no hay proveedor de inicio de sesión único configurado. Activa OIDC o vuelve a activar la contraseña antes de cerrar sesión.", + "setupConfigCheckFailed": "No se pudo verificar la configuración del inicio de sesión con contraseña. Si está desactivado, vincula el inicio de sesión único en el perfil antes de cerrar sesión." }, "user": { "menu": "Menú de usuario", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 890e3ac5..3212e78c 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -422,7 +422,14 @@ "resetPasswordTitle": "Définir un nouveau mot de passe", "resetPasswordInstruction": "Saisis ton nouveau mot de passe.", "resetPasswordSuccess": "Mot de passe réinitialisé avec succès.", - "resetPasswordSubmit": "Réinitialiser le mot de passe" + "resetPasswordSubmit": "Réinitialiser le mot de passe", + "passwordLoginDisabledHint": "La connexion par mot de passe est désactivée. Utilisez l'authentification unique ou contactez votre administrateur si vous ne pouvez pas vous connecter.", + "proxyManagedHint": "L'accès à cette instance est géré par votre proxy inverse.", + "noLoginMethodAvailable": "Aucune méthode de connexion n'est configurée. Contactez votre administrateur.", + "configLoadFailed": "Impossible de charger la configuration de connexion. Rechargez la page ou contactez votre administrateur.", + "setupLinkOidcWarning": "La connexion par mot de passe est désactivée. Liez votre compte d'authentification unique dans le profil avant de vous déconnecter, sinon vous ne pourrez plus vous reconnecter.", + "setupPasswordDisabledNoOidc": "La connexion par mot de passe est désactivée et aucun fournisseur d'authentification unique n'est configuré. Activez OIDC ou réactivez le mot de passe avant de vous déconnecter.", + "setupConfigCheckFailed": "Impossible de vérifier le réglage de connexion par mot de passe. S'il est désactivé, liez l'authentification unique dans le profil avant de vous déconnecter." }, "user": { "menu": "Menu utilisateur", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index 2691648e..3988d594 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -422,7 +422,14 @@ "resetPasswordTitle": "设置新密码", "resetPasswordInstruction": "输入你的新密码。", "resetPasswordSuccess": "密码已成功重置。", - "resetPasswordSubmit": "重置密码" + "resetPasswordSubmit": "重置密码", + "passwordLoginDisabledHint": "密码登录已禁用。请使用单点登录;如果无法登录,请联系管理员。", + "proxyManagedHint": "对此实例的访问由反向代理管理。", + "noLoginMethodAvailable": "当前未配置任何登录方式。请联系管理员。", + "configLoadFailed": "无法加载登录配置。请刷新页面或联系管理员。", + "setupLinkOidcWarning": "密码登录已禁用。请在退出登录前在个人资料中关联单点登录账户,否则将无法再次登录。", + "setupPasswordDisabledNoOidc": "密码登录已禁用且未配置单点登录提供程序。请在退出登录前启用 OIDC 或重新启用密码登录。", + "setupConfigCheckFailed": "无法验证密码登录设置。如果密码登录已禁用,请在退出登录前在个人资料中关联单点登录。" }, "user": { "menu": "用户菜单", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index acbe9b85..7fb008a5 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -327,6 +327,8 @@ export interface AppConfig { embed_enabled: boolean; dashboard_quote_enabled: boolean; thalia_cover_search_enabled: boolean; + password_login_enabled: boolean; + trusted_proxy_auth_enabled: boolean; } export interface OidcLinkStatus { diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 24de68f1..e7b8ee5b 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -19,16 +19,28 @@ let error = $state(''); let oidcEnabled = $state(false); let oidcProviderName = $state('Single Sign-On'); + let passwordLoginEnabled = $state(null); + let trustedProxyAuthEnabled = $state(false); + let configError = $state(false); $effect(() => { void (async () => { - try { - const cfg = await api.oidc.config(); - oidcEnabled = cfg.enabled; - oidcProviderName = cfg.provider_name ?? 'Single Sign-On'; - } catch { + const [oidcResult, appResult] = await Promise.allSettled([ + api.oidc.config(), + api.app.config() + ]); + if (oidcResult.status === 'fulfilled') { + oidcEnabled = oidcResult.value.enabled; + oidcProviderName = oidcResult.value.provider_name ?? 'Single Sign-On'; + } else { oidcEnabled = false; } + if (appResult.status === 'fulfilled') { + passwordLoginEnabled = appResult.value.password_login_enabled; + trustedProxyAuthEnabled = appResult.value.trusted_proxy_auth_enabled; + } else { + configError = true; + } })(); }); @@ -148,48 +160,71 @@ {error} {/if} -
{ e.preventDefault(); submit(); }}> - - -
- +
+ + {#if oidcEnabled} +
{$_('oidc.orContinueWith')}
+ + {/if} +
+ {:else} +
+ {#if oidcEnabled} + + {:else if !trustedProxyAuthEnabled} + + {$_('auth.noLoginMethodAvailable')} + + {/if} +

{$_('auth.passwordLoginDisabledHint')}

- - {#if oidcEnabled} -
{$_('oidc.orContinueWith')}
- - {/if} - + {/if} + {#if trustedProxyAuthEnabled} +

{$_('auth.proxyManagedHint')}

+ {/if} +{#if passwordLoginEnabled} +{/if} diff --git a/frontend/src/routes/login/page.test.ts b/frontend/src/routes/login/page.test.ts new file mode 100644 index 00000000..ba488093 --- /dev/null +++ b/frontend/src/routes/login/page.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/svelte'; +import LoginPage from './+page.svelte'; + +const mockOidcConfig = vi.fn(); +const mockAppConfig = vi.fn(); + +vi.mock('#lib/api.js', () => ({ + api: { + oidc: { + config: (...args: unknown[]) => mockOidcConfig(...args), + loginUrl: () => '/api/oidc/login' + }, + app: { + config: (...args: unknown[]) => mockAppConfig(...args) + }, + auth: { + login: vi.fn(), + csrf: vi.fn(), + forgotPassword: vi.fn() + }, + profile: { + getSettings: vi.fn(), + updateSettings: vi.fn() + } + } +})); + +function mockConfig({ + passwordLoginEnabled = true, + oidcEnabled = true, + trustedProxyAuthEnabled = false +} = {}) { + mockOidcConfig.mockResolvedValue({ + enabled: oidcEnabled, + provider_id: oidcEnabled ? 'oidc' : null, + provider_name: oidcEnabled ? 'Pocket ID' : null + }); + mockAppConfig.mockResolvedValue({ + embed_enabled: true, + dashboard_quote_enabled: true, + thalia_cover_search_enabled: false, + password_login_enabled: passwordLoginEnabled, + trusted_proxy_auth_enabled: trustedProxyAuthEnabled + }); +} + +describe('Login page authentication methods', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows the password form when password login is enabled', async () => { + mockConfig(); + render(LoginPage); + + await waitFor(() => { + expect(screen.getAllByLabelText('Email').length).toBeGreaterThan(0); + }); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByText('Forgot password?')).toBeInTheDocument(); + expect(screen.getByText('Continue with Pocket ID')).toBeInTheDocument(); + }); + + it('hides the password form and forgot-password link when disabled', async () => { + mockConfig({ passwordLoginEnabled: false }); + render(LoginPage); + + await waitFor(() => { + expect(screen.getByText('Continue with Pocket ID')).toBeInTheDocument(); + }); + expect(screen.queryByLabelText('Email')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); + expect(screen.queryByText('Forgot password?')).not.toBeInTheDocument(); + expect(screen.getByText(/Password login is disabled/i)).toBeInTheDocument(); + }); + + it('warns when no login method is available', async () => { + mockConfig({ passwordLoginEnabled: false, oidcEnabled: false }); + render(LoginPage); + + await waitFor(() => { + expect(screen.getByText(/No sign-in method is currently configured/i)).toBeInTheDocument(); + }); + }); + + it('shows an error instead of the form when config cannot be loaded', async () => { + mockOidcConfig.mockResolvedValue({ enabled: false, provider_id: null, provider_name: null }); + mockAppConfig.mockRejectedValue(new Error('config unavailable')); + render(LoginPage); + + await waitFor(() => { + expect(screen.getByText(/Could not load the sign-in configuration/i)).toBeInTheDocument(); + }); + expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); + }); + + it('mentions proxy-managed access when proxy auth is enabled', async () => { + mockConfig({ passwordLoginEnabled: false, oidcEnabled: false, trustedProxyAuthEnabled: true }); + render(LoginPage); + + await waitFor(() => { + expect(screen.getByText(/managed by your reverse proxy/i)).toBeInTheDocument(); + }); + expect(screen.queryByText(/No sign-in method is currently configured/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 809bc5c0..a1ef6dd5 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -42,7 +42,7 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; let keys = $state([]); let pendingDeleteKeyId = $state(null); let oidcConfig = $state({ enabled: false, provider_id: null, provider_name: null }); - let appConfig = $state({ embed_enabled: true, dashboard_quote_enabled: true, thalia_cover_search_enabled: false }); + let appConfig = $state({ embed_enabled: true, dashboard_quote_enabled: true, thalia_cover_search_enabled: false, password_login_enabled: true, trusted_proxy_auth_enabled: false }); let oidcLink = $state({ linked: false, provider_name: null, oidc_email: null, oidc_name: null }); let oidcLoading = $state(false); let oidcMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); @@ -698,23 +698,25 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; placeholder={$_('auth.lastname')} autocomplete="family-name" /> - - + {#if appConfig.password_login_enabled} + + - + + {/if} + {:else if noToken}
{$_('auth.resetPasswordMissingToken')}
diff --git a/frontend/src/routes/setup/+page.svelte b/frontend/src/routes/setup/+page.svelte index 400b57f5..819c1d94 100644 --- a/frontend/src/routes/setup/+page.svelte +++ b/frontend/src/routes/setup/+page.svelte @@ -18,6 +18,9 @@ let selectedLanguage = $state('en'); let loading = $state(false); let error = $state(''); + let passwordLoginEnabled = $state(null); + let oidcEnabled = $state(false); + let configError = $state(false); $effect(() => { if (SUPPORTED_LOCALES.includes($locale as AppLocale)) { @@ -25,6 +28,23 @@ } }); + $effect(() => { + void (async () => { + const [appResult, oidcResult] = await Promise.allSettled([ + api.app.config(), + api.oidc.config() + ]); + if (appResult.status === 'fulfilled') { + passwordLoginEnabled = appResult.value.password_login_enabled; + } else { + configError = true; + } + if (oidcResult.status === 'fulfilled') { + oidcEnabled = oidcResult.value.enabled; + } + })(); + }); + function onLanguageChange(event: Event) { const next = (event.currentTarget as HTMLSelectElement).value as AppLocale; if (!SUPPORTED_LOCALES.includes(next)) return; @@ -53,7 +73,13 @@ await api.profile.updateSettings({ language: selectedLanguage, timezone: detected }); setTimezone(detected); setLocale(selectedLanguage); - await goto('/'); + let target = '/'; + if (configError) { + target = '/profile'; + } else if (passwordLoginEnabled === false && oidcEnabled) { + target = '/profile#section-oidc'; + } + await goto(target); } catch (e: unknown) { error = e instanceof Error ? e.message : $_('auth.setupFailed'); } finally { @@ -70,6 +96,19 @@ {$_('app.title')}

{$_('auth.setupTitle')}

+ {#if configError} + + {$_('auth.setupConfigCheckFailed')} + + {:else if passwordLoginEnabled === false} + + {#if oidcEnabled} + {$_('auth.setupLinkOidcWarning')} + {:else} + {$_('auth.setupPasswordDisabledNoOidc')} + {/if} + + {/if}