diff --git a/pyproject.toml b/pyproject.toml index 19ac506..f11f568 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dev = [ "ruff>=0.5", "mypy>=1.10", "types-paramiko", + "types-Authlib", ] web = [ "fastapi>=0.115", diff --git a/src/ci_orchestrator/web/app.py b/src/ci_orchestrator/web/app.py index e75d36d..944fced 100644 --- a/src/ci_orchestrator/web/app.py +++ b/src/ci_orchestrator/web/app.py @@ -1,12 +1,15 @@ -"""FastAPI application factory for the read-only web dashboard (W4). +"""FastAPI application factory for the web dashboard (W4 read-only + W5 auth). **In-process only**: routes call ``operations/`` + ``backends.load_backend`` directly and render Jinja2 templates. They never shell out to the CLI (see ``CLAUDE.md`` / the web-UI plan's architecture rules). -**No auth in this step** (that is W5) -- the app binds ``127.0.0.1`` by -default via :class:`~ci_orchestrator.config.WebConfig`; ``create_app`` itself -never chooses the bind address, that is ``commands/web.py``'s job. +**Auth (W5)**: the four data routes below are protected by +:func:`~ci_orchestrator.web.auth.require_user`. Auth is ACTIVE iff +``config.web.oidc is not None``; when inactive (the W4 default -- bare +localhost dev), ``require_user`` is a no-op and these routes behave exactly +as in W4. ``create_app`` itself never chooses the bind address, that is +``commands/web.py``'s job. **Backend may be unavailable**: :func:`get_backend` is a FastAPI dependency that swallows :class:`~ci_orchestrator.backends.errors.BackendError` (raised @@ -29,6 +32,7 @@ from ci_orchestrator.backends.errors import BackendError from ci_orchestrator.operations.cache import CacheStatus, cargo_cache_status from ci_orchestrator.operations.host import DiskStatus, RunnerStatus, disk_status, runner_status from ci_orchestrator.operations.vms import LiveVm, list_live_vms +from ci_orchestrator.web.auth import configure_auth, require_user if TYPE_CHECKING: # pragma: no cover from ci_orchestrator.backends.protocol import VmBackend @@ -156,11 +160,16 @@ def host_partial(request: Request) -> HTMLResponse: def create_app(config: Config) -> FastAPI: - """Build the read-only dashboard app. + """Build the dashboard app. - W4 scope: no auth, read-only routes only. Binding to a concrete address - (always ``127.0.0.1`` by default -- never ``0.0.0.0``) is + Read-only routes only (mutating actions are W6). Binding to a concrete + address (always ``127.0.0.1`` by default -- never ``0.0.0.0``) is ``commands/web.py``'s job, not this factory's. + + All data routes are guarded by ``require_user`` (W5): when + ``config.web.oidc is None`` that dependency is a no-op (open mode, W4 + parity); when set, unauthenticated requests redirect to ``/login``. + ``/healthz`` and ``/static`` stay unprotected either way. """ app = FastAPI(title="ci-orchestrator web") app.state.config = config @@ -168,10 +177,22 @@ def create_app(config: Config) -> FastAPI: app.mount("/static", StaticFiles(directory=str(_WEB_DIR / "static")), name="static") app.get("/healthz")(healthz) - app.get("/", response_class=HTMLResponse)(dashboard) - app.get("/vms", response_class=HTMLResponse)(vms_partial) - app.get("/cache", response_class=HTMLResponse)(cache_partial) - app.get("/host", response_class=HTMLResponse)(host_partial) + app.get("/", response_class=HTMLResponse, dependencies=[Depends(require_user)])(dashboard) + app.get("/vms", response_class=HTMLResponse, dependencies=[Depends(require_user)])( + vms_partial + ) + app.get("/cache", response_class=HTMLResponse, dependencies=[Depends(require_user)])( + cache_partial + ) + app.get("/host", response_class=HTMLResponse, dependencies=[Depends(require_user)])( + host_partial + ) + + # SessionMiddleware (needed by the OAuth flow's state/nonce) is added + # here, before the app is ever handed to a server -- Starlette builds + # the middleware stack lazily on first request, so this is safe + # regardless of where the route registrations above sit. + configure_auth(app, config) return app diff --git a/src/ci_orchestrator/web/auth.py b/src/ci_orchestrator/web/auth.py new file mode 100644 index 0000000..203bf3c --- /dev/null +++ b/src/ci_orchestrator/web/auth.py @@ -0,0 +1,212 @@ +"""Gitea OIDC authentication for the web dashboard (W5, SECURITY-CRITICAL). + +**Auth is ACTIVE iff ``config.web.oidc is not None``.** When inactive (the +W4 default -- bare localhost dev), :func:`configure_auth` is a no-op and +:func:`require_user` degrades to a no-op that returns ``"anonymous"``; this +preserves the W4 open-localhost dashboard exactly. When active, +``SessionMiddleware`` (itsdangerous-signed cookies) is installed, an Authlib +OAuth client is registered against Gitea's OIDC discovery document, and +``/login`` / ``/auth/callback`` / ``/logout`` are wired in. + +**Secrets come from the OS keyring only** -- the session signing key +(``config.web.session_secret_cred``) and the OIDC client secret +(``config.web.oidc.client_secret_cred``) are keyring key *names* in +``Config``/TOML, never the secret values themselves (see +``credentials.KeyringCredentialStore``). Never log, template, or put a +secret value into an error message -- only the keyring key *name* may ever +appear in diagnostics. + +**Allowlist is fail-closed.** After a successful OIDC login the username is +checked against ``config.web.allowed_users``; an *empty* allowlist denies +everyone (misconfiguration must never mean "allow all"). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from authlib.integrations.starlette_client import OAuth +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import RedirectResponse +from starlette import status +from starlette.middleware.sessions import SessionMiddleware + +from ci_orchestrator.credentials import KeyringCredentialStore + +if TYPE_CHECKING: # pragma: no cover + from ci_orchestrator.config import Config + +#: Userinfo claims checked, in priority order, to derive the login username. +_USERNAME_CLAIMS = ("preferred_username", "login", "username") + + +def _read_secret(cred_name: str) -> str: + """Read a secret from the OS keyring by key *name* -- NEVER from TOML. + + Raises :class:`RuntimeError` naming only the missing keyring key + *name* (never a value) if the lookup fails, so nothing sensitive ever + reaches a log line or an error page. + """ + try: + return KeyringCredentialStore().get(cred_name).password + except KeyError as exc: + raise RuntimeError( + f"keyring key '{cred_name}' not found -- run `ci creds set {cred_name}`." + ) from exc + + +def require_user(request: Request) -> str: + """FastAPI dependency guarding a route behind a valid signed session. + + Auth is ACTIVE iff ``config.web.oidc is not None`` (see + :func:`configure_auth`). When auth is **inactive** this is a no-op that + returns ``"anonymous"`` -- the W4 open-localhost dashboard keeps working + unmodified. When **active**, a request with no ``user`` in the signed + session is redirected (303) to ``/login`` rather than rejected outright + -- 403 is reserved for "authenticated but not on the allowlist" (see + :func:`auth_callback`), never for "not logged in yet". + """ + config: Config = request.app.state.config + if config.web.oidc is None: + return "anonymous" + + user = request.session.get("user") + if not user: + raise HTTPException( + status_code=status.HTTP_303_SEE_OTHER, + headers={"Location": "/login"}, + ) + return str(user) + + +def register_oauth(config: Config) -> OAuth: + """Build an Authlib ``OAuth`` registry with the ``gitea`` client registered. + + Uses OIDC discovery (``{issuer}/.well-known/openid-configuration``). + Authlib fetches that document lazily on first use (inside + ``authorize_redirect``/``authorize_access_token``), never at + registration time -- calling this function alone never touches the + network. + """ + oidc = config.web.oidc + if oidc is None: + raise RuntimeError("register_oauth requires config.web.oidc to be set") + + oauth = OAuth() + oauth.register( + name="gitea", + server_metadata_url=f"{oidc.issuer}/.well-known/openid-configuration", + client_id=oidc.client_id, + client_secret=_read_secret(oidc.client_secret_cred), + client_kwargs={"scope": " ".join(oidc.scopes)}, + ) + return oauth + + +def _extract_username(claims: dict[str, Any]) -> str | None: + """Pick a username out of OIDC userinfo claims, Gitea-first.""" + for key in _USERNAME_CLAIMS: + value = claims.get(key) + if value: + return str(value) + return None + + +async def login(request: Request) -> Any: + """``GET /login`` -- redirect to Gitea's OIDC authorize endpoint. + + Authlib stores the CSRF ``state`` (and OIDC ``nonce``) in the signed + session itself, so ``SessionMiddleware`` must already be installed + (guaranteed: this route only exists when :func:`configure_auth` added + it, which is exactly when the middleware was also added). + """ + config: Config = request.app.state.config + oauth: OAuth = request.app.state.oauth + assert config.web.oidc is not None # route only registered when active + result: Any = await oauth.gitea.authorize_redirect( + request, redirect_uri=config.web.oidc.redirect_url + ) + return result + + +async def auth_callback(request: Request) -> RedirectResponse: + """``GET /auth/callback`` -- exchange the code, allowlist-check, set session. + + Authlib validates the OIDC ``state``/``nonce`` against the session as + part of ``authorize_access_token`` (CSRF protection on the flow) -- that + validation is never bypassed here. + + **Fail-closed allowlist**: any username not found in + ``config.web.allowed_users`` (including when that list is empty) gets a + plain ``403`` and no session is ever created. The keyring-sourced + secrets and the raw token are never included in the response. + """ + config: Config = request.app.state.config + oauth: OAuth = request.app.state.oauth + assert config.web.oidc is not None # route only registered when active + + token: dict[str, Any] = await oauth.gitea.authorize_access_token(request) + userinfo: dict[str, Any] | None = token.get("userinfo") + if not userinfo: + userinfo = await oauth.gitea.userinfo(token=token) + + username = _extract_username(dict(userinfo or {})) + if not username or username not in config.web.allowed_users: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="user not authorized" + ) + + request.session["user"] = username + return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) + + +async def logout(request: Request) -> RedirectResponse: + """``GET /logout`` -- clear the signed session and send the user to ``/login``.""" + request.session.clear() + return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER) + + +def configure_auth(app: FastAPI, config: Config) -> None: + """Wire OIDC auth into ``app`` iff ``config.web.oidc`` is set. + + Inactive (``config.web.oidc is None``): no-op, open mode (W4 parity) -- + ``require_user`` alone handles that case and the login routes are never + registered. + + Active: adds ``SessionMiddleware`` *before* anything OAuth-related runs + (Authlib needs the session for state/nonce), registers the OAuth client + on ``app.state.oauth``, and registers ``/login``, ``/auth/callback``, + ``/logout``. + """ + if config.web.oidc is None: + return + + # https_only=False: this host serves plain HTTP on the LAN (no TLS + # termination in front of it, see the web-UI plan's gotchas) -- forcing + # https_only=True here would make the browser silently drop the session + # cookie and break every login. same_site="lax" still blocks the cookie + # being sent on cross-site requests; the cookie is httponly by + # SessionMiddleware's own default (unconditional, not toggled by + # https_only). + app.add_middleware( + SessionMiddleware, + secret_key=_read_secret(config.web.session_secret_cred), + same_site="lax", + https_only=False, + ) + + app.state.oauth = register_oauth(config) + + app.add_api_route("/login", login, methods=["GET"]) + app.add_api_route("/auth/callback", auth_callback, methods=["GET"]) + app.add_api_route("/logout", logout, methods=["GET"]) + + +__all__ = [ + "auth_callback", + "configure_auth", + "login", + "logout", + "register_oauth", + "require_user", +] diff --git a/tests/python/test_web_auth.py b/tests/python/test_web_auth.py new file mode 100644 index 0000000..abf6f7e --- /dev/null +++ b/tests/python/test_web_auth.py @@ -0,0 +1,465 @@ +"""Tests for Gitea OIDC auth, signed sessions, and the allowlist (W5). + +SECURITY-CRITICAL: this suite is the contract for the whole auth layer. +No real network call, no real keyring, no real Gitea: + +- The OS keyring is faked by monkeypatching + ``KeyringCredentialStore.get`` (see :func:`_install_fake_keyring`) to + return deterministic secrets for two known keyring key *names*. +- Authlib's OAuth client is faked by swapping ``app.state.oauth.gitea`` + (built by ``register_oauth`` from real ``OAuth()``, which never touches + the network on its own -- OIDC discovery is lazy) with + :class:`_StubOAuthClient`, whose ``authorize_redirect`` / + ``authorize_access_token`` / ``userinfo`` are async and return a chosen + claim set. This is the approach the W5 plan calls out explicitly. +""" + +from __future__ import annotations + +import logging +import shutil +from pathlib import Path +from typing import Any + +import pytest +from fastapi.responses import RedirectResponse +from fastapi.testclient import TestClient + +from ci_orchestrator.config import ( + BackendConfig, + CargoCacheConfig, + Config, + Paths, + WebConfig, + WebOidcConfig, +) +from ci_orchestrator.credentials import Credential, KeyringCredentialStore +from ci_orchestrator.web.app import create_app, get_backend +from ci_orchestrator.web.auth import ( + _read_secret, + configure_auth, + register_oauth, + require_user, +) + +_SESSION_CRED_NAME = "TestWebSessionSecret" +_OIDC_CRED_NAME = "TestWebOidcClientSecret" +_FAKE_SESSION_SECRET = "sess-secret-do-not-leak-abc123" +_FAKE_OIDC_CLIENT_SECRET = "oidc-client-secret-do-not-leak-xyz987" + +_PROTECTED_ROUTES = ["/", "/vms", "/cache", "/host"] + + +# ── shared fixtures / fakes ───────────────────────────────────────────────── + + +def _install_fake_keyring(monkeypatch: pytest.MonkeyPatch) -> None: + """No real ``keyring`` package touched -- deterministic secrets only.""" + secrets = { + _SESSION_CRED_NAME: _FAKE_SESSION_SECRET, + _OIDC_CRED_NAME: _FAKE_OIDC_CLIENT_SECRET, + } + + def fake_get(self: KeyringCredentialStore, target: str) -> Credential: + if target not in secrets: + raise KeyError(target) + return Credential(username=target, password=secrets[target]) + + monkeypatch.setattr(KeyringCredentialStore, "get", fake_get) + + +def _quiet_host(monkeypatch: pytest.MonkeyPatch) -> None: + """No real disk/systemctl calls leak into route rendering.""" + from collections import namedtuple + + _DiskUsage = namedtuple("_DiskUsage", ["total", "used", "free"]) + gb = 1024**3 + monkeypatch.setattr( + shutil, "disk_usage", lambda _p: _DiskUsage(total=100 * gb, used=40 * gb, free=60 * gb) + ) + monkeypatch.setattr(shutil, "which", lambda _name: None) + + +def _make_config( + root: Path, *, active: bool, allowed_users: list[str] | None = None +) -> Config: + oidc = None + if active: + oidc = WebOidcConfig( + issuer="https://gitea.example.lan", + client_id="ci-web", + redirect_url="http://127.0.0.1:8800/auth/callback", + client_secret_cred=_OIDC_CRED_NAME, + scopes=["openid", "profile"], + ) + return Config( + paths=Paths( + root=root, + templates=root / "templates", + build_vms=root / "build-vms", + artifacts=root / "artifacts", + keys=root / "keys", + ), + backend=BackendConfig(), + cargo_cache=CargoCacheConfig( + enabled=True, root=root / "cache" / "cargo", retention_days=30 + ), + web=WebConfig( + enabled=active, + allowed_users=allowed_users if allowed_users is not None else [], + session_secret_cred=_SESSION_CRED_NAME, + oidc=oidc, + ), + ) + + +class _StubOAuthClient: + """Fake Authlib ``gitea`` client -- no network, chosen claims.""" + + def __init__( + self, + *, + token: dict[str, Any] | None = None, + userinfo_claims: dict[str, Any] | None = None, + ) -> None: + self.token: dict[str, Any] = token if token is not None else {} + self.userinfo_claims = userinfo_claims + self.userinfo_calls = 0 + self.authorize_redirect_calls: list[dict[str, Any]] = [] + + async def authorize_redirect( + self, request: Any, redirect_uri: str | None = None, **kwargs: Any + ) -> RedirectResponse: + self.authorize_redirect_calls.append({"redirect_uri": redirect_uri, **kwargs}) + return RedirectResponse( + f"https://gitea.example.lan/login/oauth/authorize?redirect_uri={redirect_uri}", + status_code=302, + ) + + async def authorize_access_token(self, request: Any, **kwargs: Any) -> dict[str, Any]: + return self.token + + async def userinfo(self, **kwargs: Any) -> dict[str, Any]: + self.userinfo_calls += 1 + return self.userinfo_claims or {} + + +def _install_stub_oauth(app: Any, stub: _StubOAuthClient) -> None: + """Swap the real (lazy-discovery) ``gitea`` client for a fake one.""" + app.state.oauth.gitea = stub + + +# ── 1. auth inactive: open mode, W4 parity ───────────────────────────────── + + +def test_auth_inactive_root_is_200(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_keyring(monkeypatch) + _quiet_host(monkeypatch) + app = create_app(_make_config(tmp_path, active=False)) + app.dependency_overrides[get_backend] = lambda: None + client = TestClient(app) + + resp = client.get("/") + + assert resp.status_code == 200 + + +def test_auth_inactive_login_routes_not_registered( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Open mode never wires /login etc. -- they simply don't exist.""" + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, active=False)) + client = TestClient(app, follow_redirects=False) + + resp = client.get("/login") + + assert resp.status_code == 404 + + +# ── 2. auth active, no session: redirect to /login ────────────────────────── + + +def test_auth_active_no_session_redirects_to_login( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, active=True, allowed_users=["simone"])) + client = TestClient(app, follow_redirects=False) + + resp = client.get("/") + + assert resp.status_code in (302, 303) + assert "/login" in resp.headers["location"] + + +@pytest.mark.parametrize("route", _PROTECTED_ROUTES) +def test_auth_active_no_session_redirects_every_protected_route( + route: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, active=True, allowed_users=["simone"])) + client = TestClient(app, follow_redirects=False) + + resp = client.get(route) + + assert resp.status_code in (302, 303) + assert "/login" in resp.headers["location"] + + +# ── 3. /healthz stays open even when auth is active ───────────────────────── + + +def test_healthz_is_200_without_session_when_auth_active( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, active=True, allowed_users=["simone"])) + client = TestClient(app, follow_redirects=False) + + resp = client.get("/healthz") + + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +def test_static_stays_open_when_auth_active( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, active=True, allowed_users=["simone"])) + client = TestClient(app, follow_redirects=False) + + resp = client.get("/static/app.css") + + assert resp.status_code == 200 + + +# ── 4. allowlisted callback -> session set -> subsequent GET / works ──────── + + +def test_callback_allowlisted_user_sets_session_and_redirects_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + _quiet_host(monkeypatch) + app = create_app(_make_config(tmp_path, active=True, allowed_users=["simone"])) + app.dependency_overrides[get_backend] = lambda: None + stub = _StubOAuthClient(token={"userinfo": {"preferred_username": "simone"}}) + _install_stub_oauth(app, stub) + client = TestClient(app, follow_redirects=False) + + callback_resp = client.get("/auth/callback") + + assert callback_resp.status_code == 303 + assert callback_resp.headers["location"] == "/" + + home_resp = client.get("/") + assert home_resp.status_code == 200 + + +def test_callback_falls_back_to_userinfo_endpoint_when_token_has_none( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, active=True, allowed_users=["simone"])) + stub = _StubOAuthClient( + token={"access_token": "at"}, userinfo_claims={"preferred_username": "simone"} + ) + _install_stub_oauth(app, stub) + client = TestClient(app, follow_redirects=False) + + resp = client.get("/auth/callback") + + assert resp.status_code == 303 + assert stub.userinfo_calls == 1 + + +def test_callback_falls_back_to_login_claim_when_preferred_username_absent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, active=True, allowed_users=["alice"])) + stub = _StubOAuthClient(token={"userinfo": {"login": "alice"}}) + _install_stub_oauth(app, stub) + client = TestClient(app, follow_redirects=False) + + resp = client.get("/auth/callback") + + assert resp.status_code == 303 + + +# ── 5. non-allowlisted user -> 403, no session ─────────────────────────────── + + +def test_callback_non_allowlisted_user_is_403_and_creates_no_session( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, active=True, allowed_users=["simone"])) + stub = _StubOAuthClient(token={"userinfo": {"preferred_username": "mallory"}}) + _install_stub_oauth(app, stub) + client = TestClient(app, follow_redirects=False) + + callback_resp = client.get("/auth/callback") + assert callback_resp.status_code == 403 + + home_resp = client.get("/") + assert home_resp.status_code in (302, 303) + assert "/login" in home_resp.headers["location"] + + +def test_callback_userinfo_with_no_recognized_username_claim_is_403( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, active=True, allowed_users=["simone"])) + stub = _StubOAuthClient(token={"userinfo": {"email": "simone@example.lan"}}) + _install_stub_oauth(app, stub) + client = TestClient(app, follow_redirects=False) + + resp = client.get("/auth/callback") + + assert resp.status_code == 403 + + +# ── 6. empty allowlist -> fail closed, even a "valid" OIDC user is denied ─── + + +def test_empty_allowlist_denies_even_a_valid_oidc_user( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, active=True, allowed_users=[])) + stub = _StubOAuthClient(token={"userinfo": {"preferred_username": "simone"}}) + _install_stub_oauth(app, stub) + client = TestClient(app, follow_redirects=False) + + resp = client.get("/auth/callback") + + assert resp.status_code == 403 + + +# ── 7. logout clears the session ──────────────────────────────────────────── + + +def test_logout_clears_session_and_subsequent_get_redirects_to_login( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + _quiet_host(monkeypatch) + app = create_app(_make_config(tmp_path, active=True, allowed_users=["simone"])) + app.dependency_overrides[get_backend] = lambda: None + stub = _StubOAuthClient(token={"userinfo": {"preferred_username": "simone"}}) + _install_stub_oauth(app, stub) + client = TestClient(app, follow_redirects=False) + + client.get("/auth/callback") + assert client.get("/").status_code == 200 # logged in + + logout_resp = client.get("/logout") + assert logout_resp.status_code == 303 + + after_resp = client.get("/") + assert after_resp.status_code in (302, 303) + assert "/login" in after_resp.headers["location"] + + +# ── 8. no secret value ever leaks into a response body or the logs ───────── + + +def test_no_secret_value_leaks_into_responses_or_logs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + _install_fake_keyring(monkeypatch) + _quiet_host(monkeypatch) + caplog.set_level(logging.DEBUG) + + app = create_app(_make_config(tmp_path, active=True, allowed_users=["simone"])) + app.dependency_overrides[get_backend] = lambda: None + allowlisted_stub = _StubOAuthClient(token={"userinfo": {"preferred_username": "simone"}}) + _install_stub_oauth(app, allowlisted_stub) + client = TestClient(app, follow_redirects=False) + + bodies: list[str] = [] + + bodies.append(client.get("/").text) # redirect to /login + bodies.append(client.get("/login").text) # real authorize_redirect not stubbed here + bodies.append(client.get("/auth/callback").text) # session set + bodies.append(client.get("/").text) # 200, dashboard + bodies.append(client.get("/logout").text) + + denied_stub = _StubOAuthClient(token={"userinfo": {"preferred_username": "mallory"}}) + _install_stub_oauth(app, denied_stub) + bodies.append(client.get("/auth/callback").text) # 403 + + for body in bodies: + assert _FAKE_SESSION_SECRET not in body + assert _FAKE_OIDC_CLIENT_SECRET not in body + + log_text = caplog.text + assert _FAKE_SESSION_SECRET not in log_text + assert _FAKE_OIDC_CLIENT_SECRET not in log_text + + +# ── require_user / _read_secret / register_oauth: direct unit coverage ───── + + +def test_require_user_inactive_returns_anonymous_without_touching_session( + tmp_path: Path, +) -> None: + from types import SimpleNamespace + + config = _make_config(tmp_path, active=False) + fake_request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(config=config))) + + assert require_user(fake_request) == "anonymous" # type: ignore[arg-type] + + +def test_read_secret_returns_keyring_password(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_keyring(monkeypatch) + + assert _read_secret(_SESSION_CRED_NAME) == _FAKE_SESSION_SECRET + + +def test_read_secret_raises_runtime_error_naming_only_the_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_fake_keyring(monkeypatch) + + with pytest.raises(RuntimeError) as exc_info: + _read_secret("SomeMissingCred") + + message = str(exc_info.value) + assert "SomeMissingCred" in message + assert _FAKE_SESSION_SECRET not in message + assert _FAKE_OIDC_CLIENT_SECRET not in message + + +def test_register_oauth_raises_without_oidc_config(tmp_path: Path) -> None: + config = _make_config(tmp_path, active=False) + + with pytest.raises(RuntimeError): + register_oauth(config) + + +def test_register_oauth_builds_gitea_client_without_network( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + config = _make_config(tmp_path, active=True, allowed_users=["simone"]) + + oauth = register_oauth(config) + + assert oauth.gitea is not None + + +def test_configure_auth_noop_when_oidc_none(tmp_path: Path) -> None: + from fastapi import FastAPI + + app = FastAPI() + config = _make_config(tmp_path, active=False) + + configure_auth(app, config) + + assert not hasattr(app.state, "oauth")