feat(web): Gitea OIDC auth, signed session, allowlist (W5)

This commit is contained in:
2026-07-23 17:51:36 +02:00
parent 10b5bef1fc
commit bc421e5009
4 changed files with 710 additions and 11 deletions
+32 -11
View File
@@ -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
+212
View File
@@ -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",
]