"""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 expired_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, ) # A token of the same valid session whose lifetime ended a minute ago. expired = TOKEN_PREFIX + secrets.token_urlsafe(32) store.tokens[token_hash(expired)] = TokenRecord( kind="access", session_id=session_id, client_id=OAUTH_CLIENT_ID, scopes=[MCP_SCOPE], expires_at=time.time() - 60, ) 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, expired_access_token=expired, secrets=[ bearer, access, expired, 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: every # admin list endpoint and an admin create, all for this non-admin user. for tool in ( "gitea_admin_cron_list", "gitea_admin_search_users", "gitea_admin_get_all_orgs", "gitea_admin_get_all_emails", "gitea_admin_list_hooks", "gitea_admin_list_packages", "gitea_admin_get_runners", "gitea_admin_unadopted_list", ): refused = await mcp.call(tool) assert refused.is_error and refused.status == 403, tool assert refused.error_kind == "forbidden", tool assert refused.identity["mode"] == "oauth" created = await mcp.call( "gitea_admin_create_user", body={ "username": gitea.unique("it-nope"), "email": f"{gitea.unique('nope')}@example.test", "password": secrets.token_urlsafe(24), }, ) assert created.is_error and created.status == 403 assert created.error_kind == "forbidden" 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_admin_create_user", } 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 stack.expired_access_token, # ours and known, but past its expiry 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 # The expiry is what excluded the token above: the live one of the same session works. live = await http_client.post( "/mcp", json=body, headers={**headers, "Authorization": f"Bearer {stack.user_access_token}"}, ) assert live.status_code == 200