feat(web): Gitea OIDC auth, signed session, allowlist (W5)
This commit is contained in:
@@ -26,6 +26,7 @@ dev = [
|
|||||||
"ruff>=0.5",
|
"ruff>=0.5",
|
||||||
"mypy>=1.10",
|
"mypy>=1.10",
|
||||||
"types-paramiko",
|
"types-paramiko",
|
||||||
|
"types-Authlib",
|
||||||
]
|
]
|
||||||
web = [
|
web = [
|
||||||
"fastapi>=0.115",
|
"fastapi>=0.115",
|
||||||
|
|||||||
@@ -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``
|
**In-process only**: routes call ``operations/`` + ``backends.load_backend``
|
||||||
directly and render Jinja2 templates. They never shell out to the CLI (see
|
directly and render Jinja2 templates. They never shell out to the CLI (see
|
||||||
``CLAUDE.md`` / the web-UI plan's architecture rules).
|
``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
|
**Auth (W5)**: the four data routes below are protected by
|
||||||
default via :class:`~ci_orchestrator.config.WebConfig`; ``create_app`` itself
|
:func:`~ci_orchestrator.web.auth.require_user`. Auth is ACTIVE iff
|
||||||
never chooses the bind address, that is ``commands/web.py``'s job.
|
``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
|
**Backend may be unavailable**: :func:`get_backend` is a FastAPI dependency
|
||||||
that swallows :class:`~ci_orchestrator.backends.errors.BackendError` (raised
|
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.cache import CacheStatus, cargo_cache_status
|
||||||
from ci_orchestrator.operations.host import DiskStatus, RunnerStatus, disk_status, runner_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.operations.vms import LiveVm, list_live_vms
|
||||||
|
from ci_orchestrator.web.auth import configure_auth, require_user
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from ci_orchestrator.backends.protocol import VmBackend
|
from ci_orchestrator.backends.protocol import VmBackend
|
||||||
@@ -156,11 +160,16 @@ def host_partial(request: Request) -> HTMLResponse:
|
|||||||
|
|
||||||
|
|
||||||
def create_app(config: Config) -> FastAPI:
|
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
|
Read-only routes only (mutating actions are W6). Binding to a concrete
|
||||||
(always ``127.0.0.1`` by default -- never ``0.0.0.0``) is
|
address (always ``127.0.0.1`` by default -- never ``0.0.0.0``) is
|
||||||
``commands/web.py``'s job, not this factory's.
|
``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 = FastAPI(title="ci-orchestrator web")
|
||||||
app.state.config = config
|
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.mount("/static", StaticFiles(directory=str(_WEB_DIR / "static")), name="static")
|
||||||
|
|
||||||
app.get("/healthz")(healthz)
|
app.get("/healthz")(healthz)
|
||||||
app.get("/", response_class=HTMLResponse)(dashboard)
|
app.get("/", response_class=HTMLResponse, dependencies=[Depends(require_user)])(dashboard)
|
||||||
app.get("/vms", response_class=HTMLResponse)(vms_partial)
|
app.get("/vms", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
|
||||||
app.get("/cache", response_class=HTMLResponse)(cache_partial)
|
vms_partial
|
||||||
app.get("/host", response_class=HTMLResponse)(host_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
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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": "[email protected]"}})
|
||||||
|
_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")
|
||||||
Reference in New Issue
Block a user