ci / gitleaks (push) Successful in 8s
ci / docs-check (push) Failing after 30s
ci / lint (push) Successful in 42s
ci / test-integration (push) Successful in 46s
ci / security (push) Failing after 27s
ci / test-mcp (push) Successful in 1m16s
ci / test-unit (push) Successful in 2m17s
ci / build-and-push (push) Successful in 17s
Identity gains a redact field that the executor honours, so the OAuth resolver no longer needs an executor subclass overriding private internals. Opaque agm_ tokens issued by our authorization server are now masked by the secret patterns wherever they appear in content.
189 lines
6.5 KiB
Python
189 lines
6.5 KiB
Python
"""Startup and wiring of per-user OAuth: OAuth-only deployments, store failures, log hygiene."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pytest_httpx import HTTPXMock
|
|
|
|
from aegis_gitea_mcp import transports
|
|
from aegis_gitea_mcp.gitea import GiteaExecutor
|
|
from aegis_gitea_mcp.oauth import OAuthSetupError
|
|
from aegis_gitea_mcp.oauth import components as components_module
|
|
from aegis_gitea_mcp.oauth.components import RATE_LIMITED_PATHS, build_oauth_components
|
|
from aegis_gitea_mcp.oauth.store import STORE_FILENAME
|
|
from aegis_gitea_mcp.transports import AccessLogQueryFilter, http_startup_errors, run_http
|
|
from tests.test_oauth_helpers import (
|
|
API,
|
|
BEARER_TOKEN,
|
|
USER_TOKEN,
|
|
browser,
|
|
build_oauth_app,
|
|
gitea_requests,
|
|
mcp_client,
|
|
oauth_settings,
|
|
payload,
|
|
sign_in,
|
|
)
|
|
from tests.test_server_helpers import ANY_HOST
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _env(mock_env: None) -> None:
|
|
"""The audit logger reads the process-wide settings."""
|
|
|
|
|
|
class UvicornSpy:
|
|
def __init__(self) -> None:
|
|
self.calls: list[tuple[Any, dict[str, Any]]] = []
|
|
|
|
def __call__(self, app: Any, **kwargs: Any) -> None:
|
|
self.calls.append((app, kwargs))
|
|
|
|
|
|
@pytest.fixture
|
|
def uvicorn_spy(monkeypatch: pytest.MonkeyPatch) -> UvicornSpy:
|
|
spy = UvicornSpy()
|
|
monkeypatch.setattr(transports.uvicorn, "run", spy)
|
|
monkeypatch.setattr(logging.getLogger("uvicorn.access"), "filters", [])
|
|
return spy
|
|
|
|
|
|
def oauth_only(**overrides: Any) -> Any:
|
|
return oauth_settings(gitea_token=None, mcp_bearer_token=None, **overrides)
|
|
|
|
|
|
def test_oauth_only_deployment_starts_on_a_public_bind(uvicorn_spy: UvicornSpy) -> None:
|
|
assert run_http(oauth_only(mcp_host=ANY_HOST)) == 0
|
|
|
|
assert len(uvicorn_spy.calls) == 1
|
|
assert (
|
|
http_startup_errors(oauth_only(mcp_host=ANY_HOST), build_oauth_components(oauth_only()))
|
|
== []
|
|
)
|
|
|
|
|
|
def test_oauth_with_a_bearer_but_no_gitea_token_is_refused(
|
|
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
assert run_http(oauth_settings(gitea_token=None)) == 2
|
|
|
|
err = capsys.readouterr().err
|
|
assert "GITEA_TOKEN is required" in err and "unset MCP_BEARER_TOKEN" in err
|
|
assert uvicorn_spy.calls == []
|
|
|
|
|
|
def test_undecryptable_store_stops_startup_and_keeps_the_file(
|
|
tmp_path: Path, uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
(tmp_path / STORE_FILENAME).write_bytes(b"garbage")
|
|
|
|
assert run_http(oauth_settings(data_dir=tmp_path)) == 2
|
|
|
|
err = capsys.readouterr().err
|
|
assert "per-user OAuth cannot start: cannot decrypt" in err
|
|
assert (tmp_path / STORE_FILENAME).read_bytes() == b"garbage"
|
|
assert uvicorn_spy.calls == []
|
|
|
|
|
|
def test_unreadable_store_directory_is_a_setup_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def denied(*args: Any, **kwargs: Any) -> None:
|
|
raise PermissionError("denied")
|
|
|
|
monkeypatch.setattr(components_module.OAuthStore, "load", denied)
|
|
|
|
with pytest.raises(OAuthSetupError, match="cannot read the OAuth token store"):
|
|
build_oauth_components(oauth_settings())
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"settings",
|
|
[
|
|
{"oauth_enabled": False},
|
|
{"gitea_oauth_client_secret": None},
|
|
],
|
|
)
|
|
def test_components_need_a_complete_oauth_configuration(settings: dict[str, Any]) -> None:
|
|
with pytest.raises(OAuthSetupError):
|
|
build_oauth_components(oauth_settings(**settings))
|
|
|
|
|
|
def test_http_public_base_url_is_refused_for_oauth() -> None:
|
|
settings = oauth_settings().model_copy(update={"public_base_url": "http://mcp.example.com"})
|
|
|
|
errors = http_startup_errors(settings, build_oauth_components(oauth_settings()))
|
|
|
|
assert errors == ["PUBLIC_BASE_URL must use https unless the host is loopback"]
|
|
|
|
|
|
def test_loopback_http_public_base_url_is_allowed() -> None:
|
|
settings = oauth_settings(public_base_url="http://127.0.0.1:8080")
|
|
|
|
assert http_startup_errors(settings, build_oauth_components(settings)) == []
|
|
|
|
|
|
def test_components_wiring() -> None:
|
|
settings = oauth_settings()
|
|
extras = build_oauth_components(settings)
|
|
|
|
assert isinstance(extras.executor, GiteaExecutor)
|
|
assert extras.rate_limited_paths == RATE_LIMITED_PATHS
|
|
paths = {route.path for route in extras.extra_routes} # type: ignore[attr-defined]
|
|
assert paths == {"/oauth/consent", "/oauth/callback", "/.well-known/oauth-protected-resource"}
|
|
assert extras.client_registration_options is not None
|
|
assert extras.client_registration_options.default_scopes == ["gitea"]
|
|
assert extras.revocation_options is not None and extras.revocation_options.enabled
|
|
assert extras.resolver._admin is not None # type: ignore[attr-defined]
|
|
assert build_oauth_components(oauth_only()).resolver._admin is None # type: ignore[union-attr]
|
|
|
|
|
|
async def test_oauth_only_app_serves_users_and_has_no_admin_path(httpx_mock: HTTPXMock) -> None:
|
|
oauth = build_oauth_app(oauth_only())
|
|
flow = sign_in(browser(oauth.app), httpx_mock)
|
|
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"full_name": "octo/demo"})
|
|
|
|
async with mcp_client(oauth.app, flow.tokens["access_token"]) as mcp:
|
|
result = await mcp.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo"})
|
|
|
|
assert payload(result)["identity"]["mode"] == "oauth"
|
|
(request,) = gitea_requests(httpx_mock, f"{API}/repos")
|
|
assert request.headers["Authorization"] == f"Bearer {USER_TOKEN}"
|
|
rejected = browser(oauth.app).post(
|
|
"/mcp", json={}, headers={"Authorization": f"Bearer {BEARER_TOKEN}"}
|
|
)
|
|
assert rejected.status_code == 401
|
|
|
|
|
|
def test_access_log_filter_drops_query_strings() -> None:
|
|
record = logging.LogRecord(
|
|
"uvicorn.access",
|
|
logging.INFO,
|
|
__file__,
|
|
1,
|
|
'%s - "%s %s HTTP/%s" %d',
|
|
("1.2.3.4:5", "GET", "/oauth/callback?code=secret-code&state=secret-state", "1.1", 302),
|
|
None,
|
|
)
|
|
plain = logging.LogRecord(
|
|
"uvicorn.access", logging.INFO, __file__, 1, "%s", ("no query",), None
|
|
)
|
|
|
|
assert AccessLogQueryFilter().filter(record) is True
|
|
assert AccessLogQueryFilter().filter(plain) is True
|
|
|
|
message = record.getMessage()
|
|
assert "secret" not in message
|
|
assert '"GET /oauth/callback?[redacted] HTTP/1.1" 302' in message
|
|
assert plain.getMessage() == "no query"
|
|
|
|
|
|
def test_run_http_installs_the_access_log_filter_once(uvicorn_spy: UvicornSpy) -> None:
|
|
run_http(oauth_settings())
|
|
run_http(oauth_settings())
|
|
|
|
filters = logging.getLogger("uvicorn.access").filters
|
|
assert sum(isinstance(item, AccessLogQueryFilter) for item in filters) == 1
|