test: per-user OAuth mode against the live instance
This commit is contained in:
1 parent
303bd9aa21
commit
fed75b17c8
1 file changed
+266
@@ -0,0 +1,266 @@
|
|||||||
|
"""Per-user mode against the live instance: calls run as the signed-in Gitea user.
|
||||||
|
|
||||||
|
The OAuth store is seeded directly (a session for a non-admin user whose Gitea token is a
|
||||||
|
personal access token, plus our own issued access token) instead of running the browser
|
||||||
|
flow, then the real HTTP app is driven in process with the MCP SDK client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx2
|
||||||
|
import pytest
|
||||||
|
from mcp import Client
|
||||||
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.audit import AuditLogger
|
||||||
|
from aegis_gitea_mcp.auth import MCP_SCOPE
|
||||||
|
from aegis_gitea_mcp.config import Settings
|
||||||
|
from aegis_gitea_mcp.oauth.components import build_oauth_components
|
||||||
|
from aegis_gitea_mcp.oauth.provider import ACCESS_TOKEN_TTL, TOKEN_PREFIX
|
||||||
|
from aegis_gitea_mcp.oauth.store import OAuthStore, SessionRecord, TokenRecord, token_hash
|
||||||
|
from aegis_gitea_mcp.server import build_server
|
||||||
|
from aegis_gitea_mcp.transports import build_http_app
|
||||||
|
from tests.integration.conftest import McpSession, RegularUser
|
||||||
|
from tests.integration.harness import GiteaInstance
|
||||||
|
|
||||||
|
PUBLIC_BASE = "https://mcp.example.test"
|
||||||
|
RESOURCE = f"{PUBLIC_BASE}/mcp"
|
||||||
|
ENCRYPTION_KEY = secrets.token_urlsafe(48)
|
||||||
|
OAUTH_CLIENT_ID = "it-oauth-client"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OAuthStack:
|
||||||
|
"""The HTTP app with its seeded tokens and the secrets the tests must never see."""
|
||||||
|
|
||||||
|
app: Starlette
|
||||||
|
audit_path: Path
|
||||||
|
bearer: str
|
||||||
|
user_login: str
|
||||||
|
user_access_token: str
|
||||||
|
secrets: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
async def build_stack(
|
||||||
|
gitea: GiteaInstance, user: RegularUser, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> OAuthStack:
|
||||||
|
"""Build the app with per-user OAuth enabled and a signed-in session for ``user``."""
|
||||||
|
monkeypatch.setenv("GITEA_URL", gitea.base_url)
|
||||||
|
data_dir = tmp_path / "oauth-state"
|
||||||
|
bearer = secrets.token_urlsafe(36)
|
||||||
|
oauth_client_secret = secrets.token_urlsafe(36)
|
||||||
|
settings = Settings(
|
||||||
|
gitea_url=gitea.base_url,
|
||||||
|
gitea_token=gitea.admin_token,
|
||||||
|
mcp_bearer_token=bearer,
|
||||||
|
oauth_enabled=True,
|
||||||
|
gitea_oauth_client_id=OAUTH_CLIENT_ID,
|
||||||
|
gitea_oauth_client_secret=oauth_client_secret,
|
||||||
|
token_encryption_key=ENCRYPTION_KEY,
|
||||||
|
public_base_url=PUBLIC_BASE,
|
||||||
|
data_dir=data_dir,
|
||||||
|
audit_log_path=data_dir / "audit.log",
|
||||||
|
request_timeout_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Seed the encrypted store: one session (the user's own Gitea token) and one of our
|
||||||
|
# access tokens (stored only as a hash), exactly what the browser flow would leave.
|
||||||
|
store = OAuthStore.load(data_dir, ENCRYPTION_KEY)
|
||||||
|
session_id = secrets.token_hex(16)
|
||||||
|
store.sessions[session_id] = SessionRecord(
|
||||||
|
session_id=session_id,
|
||||||
|
client_id=OAUTH_CLIENT_ID,
|
||||||
|
login=user.login,
|
||||||
|
is_admin=False,
|
||||||
|
gitea_access_token=user.token,
|
||||||
|
gitea_refresh_token=None,
|
||||||
|
gitea_expires_at=None,
|
||||||
|
scopes=[MCP_SCOPE],
|
||||||
|
resource=RESOURCE,
|
||||||
|
created_at=time.time(),
|
||||||
|
)
|
||||||
|
access = TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||||
|
store.tokens[token_hash(access)] = TokenRecord(
|
||||||
|
kind="access",
|
||||||
|
session_id=session_id,
|
||||||
|
client_id=OAUTH_CLIENT_ID,
|
||||||
|
scopes=[MCP_SCOPE],
|
||||||
|
expires_at=time.time() + ACCESS_TOKEN_TTL,
|
||||||
|
)
|
||||||
|
await store.save()
|
||||||
|
|
||||||
|
audit = AuditLogger(settings.resolved_audit_log_path)
|
||||||
|
extras = build_oauth_components(settings, audit=audit)
|
||||||
|
server = build_server(settings, resolver=extras.resolver, executor=extras.executor, audit=audit)
|
||||||
|
app = build_http_app(
|
||||||
|
settings,
|
||||||
|
server=server,
|
||||||
|
token_verifier=extras.token_verifier,
|
||||||
|
auth_server_provider=extras.auth_server_provider,
|
||||||
|
extra_routes=extras.extra_routes,
|
||||||
|
client_registration_options=extras.client_registration_options,
|
||||||
|
revocation_options=extras.revocation_options,
|
||||||
|
rate_limited_paths=extras.rate_limited_paths,
|
||||||
|
)
|
||||||
|
return OAuthStack(
|
||||||
|
app=app,
|
||||||
|
audit_path=settings.resolved_audit_log_path,
|
||||||
|
bearer=bearer,
|
||||||
|
user_login=user.login,
|
||||||
|
user_access_token=access,
|
||||||
|
secrets=[
|
||||||
|
bearer,
|
||||||
|
access,
|
||||||
|
user.token,
|
||||||
|
gitea.admin_token,
|
||||||
|
oauth_client_secret,
|
||||||
|
ENCRYPTION_KEY,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def http_session(app: Starlette, token: str, audit_path: Path) -> AsyncIterator[McpSession]:
|
||||||
|
"""An MCP session over the real HTTP app (in process) with ``token`` as bearer."""
|
||||||
|
async with (
|
||||||
|
app.router.lifespan_context(app),
|
||||||
|
httpx2.AsyncClient(
|
||||||
|
transport=httpx2.ASGITransport(app=app),
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
base_url=PUBLIC_BASE,
|
||||||
|
timeout=60.0,
|
||||||
|
) as http_client,
|
||||||
|
Client(streamable_http_client(RESOURCE, http_client=http_client)) as client,
|
||||||
|
):
|
||||||
|
yield McpSession(client, audit_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def stack(
|
||||||
|
gitea: GiteaInstance,
|
||||||
|
regular_user: RegularUser,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> OAuthStack:
|
||||||
|
return await build_stack(gitea, regular_user, tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_calls_run_as_the_signed_in_user(
|
||||||
|
gitea: GiteaInstance, regular_user: RegularUser, stack: OAuthStack
|
||||||
|
) -> None:
|
||||||
|
private_repo = gitea.unique("it-private")
|
||||||
|
created = gitea.api(
|
||||||
|
"POST",
|
||||||
|
f"/admin/users/{gitea.admin_login}/repos",
|
||||||
|
json={"name": private_repo, "private": True, "auto_init": True},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
try:
|
||||||
|
async with http_session(stack.app, stack.user_access_token, stack.audit_path) as mcp:
|
||||||
|
me = await mcp.ok("gitea_user_get_current")
|
||||||
|
assert me.identity == {"login": regular_user.login, "mode": "oauth", "sudo": None}
|
||||||
|
assert me.data["login"] == regular_user.login
|
||||||
|
assert me.data["is_admin"] is False
|
||||||
|
|
||||||
|
# The user can create and read their own repository ...
|
||||||
|
own = gitea.unique("it-own")
|
||||||
|
await mcp.ok("gitea_user_current_create_repo", status=201, body={"name": own})
|
||||||
|
assert (await mcp.ok("gitea_repo_get", owner=regular_user.login, repo=own)).data[
|
||||||
|
"owner"
|
||||||
|
]["login"] == regular_user.login
|
||||||
|
|
||||||
|
# ... but not another user's private repository: Gitea hides it.
|
||||||
|
hidden = await mcp.call("gitea_repo_get", owner=gitea.admin_login, repo=private_repo)
|
||||||
|
assert hidden.is_error
|
||||||
|
assert hidden.status in (403, 404)
|
||||||
|
assert hidden.error_kind in ("forbidden", "not_found")
|
||||||
|
assert (
|
||||||
|
await mcp.call(
|
||||||
|
"gitea_repo_get_contents",
|
||||||
|
owner=gitea.admin_login,
|
||||||
|
repo=private_repo,
|
||||||
|
filepath="README.md",
|
||||||
|
)
|
||||||
|
).status in (403, 404)
|
||||||
|
|
||||||
|
# Admin tools and sudo are refused by Gitea with 403, not by the server.
|
||||||
|
admin_only = await mcp.call("gitea_admin_cron_list")
|
||||||
|
assert admin_only.is_error and admin_only.status == 403
|
||||||
|
assert admin_only.error_kind == "forbidden"
|
||||||
|
assert admin_only.identity["mode"] == "oauth"
|
||||||
|
sudo = await mcp.call("gitea_user_get_current", sudo=gitea.admin_login)
|
||||||
|
assert sudo.is_error and sudo.status == 403
|
||||||
|
|
||||||
|
entries = mcp.audit_entries()
|
||||||
|
assert entries and all(e["identity_mode"] == "oauth" for e in entries)
|
||||||
|
assert all(e["identity_login"] == regular_user.login for e in entries)
|
||||||
|
assert {e["tool_name"] for e in entries} >= {
|
||||||
|
"gitea_user_get_current",
|
||||||
|
"gitea_admin_cron_list",
|
||||||
|
}
|
||||||
|
gitea.api("DELETE", f"/repos/{regular_user.login}/{own}")
|
||||||
|
|
||||||
|
texts = "\n".join(mcp.texts) + stack.audit_path.read_text(encoding="utf-8")
|
||||||
|
for secret in stack.secrets:
|
||||||
|
assert secret not in texts
|
||||||
|
finally:
|
||||||
|
gitea.api("DELETE", f"/repos/{gitea.admin_login}/{private_repo}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_static_bearer_in_the_same_app_runs_as_admin(
|
||||||
|
gitea: GiteaInstance, stack: OAuthStack
|
||||||
|
) -> None:
|
||||||
|
async with http_session(stack.app, stack.bearer, stack.audit_path) as mcp:
|
||||||
|
me = await mcp.ok("gitea_user_get_current")
|
||||||
|
assert me.identity == {"login": gitea.admin_login, "mode": "admin-token", "sudo": None}
|
||||||
|
assert me.data["is_admin"] is True
|
||||||
|
cron = await mcp.ok("gitea_admin_cron_list")
|
||||||
|
assert cron.data
|
||||||
|
entries = mcp.audit_entries()
|
||||||
|
assert {e["identity_mode"] for e in entries} == {"admin-token"}
|
||||||
|
texts = "\n".join(mcp.texts)
|
||||||
|
for secret in stack.secrets:
|
||||||
|
assert secret not in texts
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_and_expired_tokens_never_reach_gitea(
|
||||||
|
stack: OAuthStack, regular_user: RegularUser
|
||||||
|
) -> None:
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2025-11-25",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "it", "version": "0"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
headers = {"Accept": "application/json, text/event-stream"}
|
||||||
|
async with (
|
||||||
|
stack.app.router.lifespan_context(stack.app),
|
||||||
|
httpx2.AsyncClient(
|
||||||
|
transport=httpx2.ASGITransport(app=stack.app), base_url=PUBLIC_BASE
|
||||||
|
) as http_client,
|
||||||
|
):
|
||||||
|
for token in (
|
||||||
|
TOKEN_PREFIX + secrets.token_urlsafe(32), # looks like ours, unknown
|
||||||
|
regular_user.token, # the user's own Gitea token is not an MCP token
|
||||||
|
secrets.token_urlsafe(36),
|
||||||
|
):
|
||||||
|
response = await http_client.post(
|
||||||
|
"/mcp", json=body, headers={**headers, "Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert token not in response.text
|
||||||
|
anonymous = await http_client.post("/mcp", json=body, headers=headers)
|
||||||
|
assert anonymous.status_code == 401
|
||||||
Reference in new issue
Block a user