304 lines
9.7 KiB
Python
304 lines
9.7 KiB
Python
"""Shared builders for the per-user OAuth tests: settings, a fake clock and the browser flow."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import secrets
|
|
import time
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
import httpx
|
|
import httpx2
|
|
from mcp import Client
|
|
from mcp.client.streamable_http import streamable_http_client
|
|
from pytest_httpx import HTTPXMock
|
|
from starlette.applications import Starlette
|
|
from starlette.testclient import TestClient
|
|
|
|
from aegis_gitea_mcp.audit import AuditLogger
|
|
from aegis_gitea_mcp.config import Settings
|
|
from aegis_gitea_mcp.oauth.components import build_oauth_components
|
|
from aegis_gitea_mcp.server import build_server
|
|
from aegis_gitea_mcp.transports import HttpAuthExtras, build_http_app
|
|
from tests.test_executor_helpers import API, BEARER_TOKEN, GITEA_TOKEN
|
|
|
|
BASE = "https://mcp.example.com"
|
|
RESOURCE = f"{BASE}/mcp"
|
|
GITEA = "https://gitea.example.com"
|
|
TOKEN_URL = f"{GITEA}/login/oauth/access_token"
|
|
USER_URL = f"{API}/user"
|
|
APP_CLIENT_ID = "gitea-app-client-id"
|
|
APP_CLIENT_SECRET = "gitea-app-client-secret-0123456789abcdef"
|
|
ENCRYPTION_KEY = "token-encryption-key-0123456789abcdefghijklmnop"
|
|
USER_TOKEN = "gitea-user-access-token-0123456789abcdef"
|
|
USER_REFRESH = "gitea-user-refresh-token-0123456789abcdef"
|
|
GITEA_CODE = "gitea-authorization-code-0123456789"
|
|
REDIRECT = "http://localhost:3000/callback"
|
|
CLIENT_STATE = "client-state-value-123"
|
|
|
|
__all__ = [
|
|
"API",
|
|
"BEARER_TOKEN",
|
|
"GITEA_TOKEN",
|
|
]
|
|
|
|
|
|
class FakeClock:
|
|
"""A controllable wall clock starting at the real time."""
|
|
|
|
def __init__(self) -> None:
|
|
self.now = time.time()
|
|
|
|
def __call__(self) -> float:
|
|
return self.now
|
|
|
|
def advance(self, seconds: float) -> None:
|
|
self.now += seconds
|
|
|
|
|
|
def oauth_settings(**overrides: Any) -> Settings:
|
|
"""A complete per-user OAuth configuration with a static bearer and an admin token."""
|
|
values: dict[str, Any] = {
|
|
"gitea_url": GITEA,
|
|
"gitea_token": GITEA_TOKEN,
|
|
"mcp_bearer_token": BEARER_TOKEN,
|
|
"mcp_port": 8080,
|
|
"oauth_enabled": True,
|
|
"gitea_oauth_client_id": APP_CLIENT_ID,
|
|
"gitea_oauth_client_secret": APP_CLIENT_SECRET,
|
|
"token_encryption_key": ENCRYPTION_KEY,
|
|
"public_base_url": BASE,
|
|
}
|
|
values.update(overrides)
|
|
return Settings(**values)
|
|
|
|
|
|
@dataclass
|
|
class OAuthApp:
|
|
"""The HTTP app with its OAuth pieces."""
|
|
|
|
app: Starlette
|
|
extras: HttpAuthExtras
|
|
settings: Settings
|
|
clock: FakeClock
|
|
|
|
@property
|
|
def provider(self) -> Any:
|
|
return self.extras.auth_server_provider
|
|
|
|
@property
|
|
def store(self) -> Any:
|
|
return self.provider._store
|
|
|
|
|
|
def build_oauth_app(
|
|
settings: Settings | None = None,
|
|
clock: FakeClock | None = None,
|
|
audit: AuditLogger | None = None,
|
|
) -> OAuthApp:
|
|
"""Build the full HTTP app the way ``run_http`` does, with a fake clock."""
|
|
settings = settings or oauth_settings()
|
|
clock = clock or FakeClock()
|
|
extras = build_oauth_components(settings, clock=clock, audit=audit)
|
|
server = build_server(settings, resolver=extras.resolver, executor=extras.executor)
|
|
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 OAuthApp(app=app, extras=extras, settings=settings, clock=clock)
|
|
|
|
|
|
def browser(app: Starlette) -> TestClient:
|
|
"""A browser-like client (cookies, no redirect following). No lifespan is started."""
|
|
return TestClient(app, base_url=BASE, follow_redirects=False)
|
|
|
|
|
|
def pkce() -> tuple[str, str]:
|
|
verifier = secrets.token_urlsafe(48)
|
|
digest = hashlib.sha256(verifier.encode()).digest()
|
|
return verifier, base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
|
|
|
|
|
def query_of(location: str) -> dict[str, str]:
|
|
return {key: values[0] for key, values in parse_qs(urlsplit(location).query).items()}
|
|
|
|
|
|
def register(client: TestClient, redirect_uris: list[str] | None = None, **extra: Any) -> Any:
|
|
body = {
|
|
"redirect_uris": redirect_uris or [REDIRECT],
|
|
"client_name": "Test client",
|
|
**extra,
|
|
}
|
|
return client.post("/register", json=body)
|
|
|
|
|
|
def mock_gitea_login(
|
|
httpx_mock: HTTPXMock,
|
|
*,
|
|
login: str = "alice",
|
|
is_admin: bool = False,
|
|
access: str = USER_TOKEN,
|
|
refresh: str | None = USER_REFRESH,
|
|
expires_in: int | None = 3600,
|
|
) -> None:
|
|
body: dict[str, Any] = {"access_token": access, "token_type": "bearer"}
|
|
if refresh is not None:
|
|
body["refresh_token"] = refresh
|
|
if expires_in is not None:
|
|
body["expires_in"] = expires_in
|
|
httpx_mock.add_response(method="POST", url=TOKEN_URL, json=body)
|
|
httpx_mock.add_response(
|
|
method="GET", url=USER_URL, json={"login": login, "is_admin": is_admin, "id": 7}
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class SignIn:
|
|
"""Everything a completed browser flow produced."""
|
|
|
|
client_id: str
|
|
client_secret: str
|
|
verifier: str
|
|
code: str
|
|
tokens: dict[str, Any] = field(default_factory=dict)
|
|
gitea_url: str = ""
|
|
|
|
|
|
CSRF_FIELD = re.compile(r'name="csrf" value="([^"]+)"')
|
|
|
|
|
|
def start_flow(client: TestClient, registration: dict[str, Any], challenge: str) -> str:
|
|
"""``/authorize`` -> consent page -> approve; return Gitea's authorization URL."""
|
|
authorize = client.get(
|
|
"/authorize",
|
|
params={
|
|
"client_id": registration["client_id"],
|
|
"redirect_uri": REDIRECT,
|
|
"response_type": "code",
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
"state": CLIENT_STATE,
|
|
"resource": RESOURCE,
|
|
},
|
|
)
|
|
assert authorize.status_code == 302, authorize.text
|
|
consent_url = authorize.headers["location"]
|
|
assert consent_url.startswith(f"{BASE}/oauth/consent?request=")
|
|
page = client.get(consent_url)
|
|
assert page.status_code == 200
|
|
match = CSRF_FIELD.search(page.text)
|
|
assert match is not None
|
|
approved = client.post(
|
|
"/oauth/consent",
|
|
data={
|
|
"request": query_of(consent_url)["request"],
|
|
"csrf": match.group(1),
|
|
"decision": "approve",
|
|
},
|
|
headers={"Origin": BASE},
|
|
)
|
|
assert approved.status_code == 303, approved.text
|
|
return str(approved.headers["location"])
|
|
|
|
|
|
def sign_in(
|
|
client: TestClient, httpx_mock: HTTPXMock, *, login: str = "alice", **gitea: Any
|
|
) -> SignIn:
|
|
"""Run the whole flow (DCR, consent, Gitea, callback, /token) for one user."""
|
|
registration = register(client).json()
|
|
verifier, challenge = pkce()
|
|
gitea_url = start_flow(client, registration, challenge)
|
|
mock_gitea_login(httpx_mock, login=login, **gitea)
|
|
callback = client.get(
|
|
"/oauth/callback", params={"code": GITEA_CODE, "state": query_of(gitea_url)["state"]}
|
|
)
|
|
assert callback.status_code == 302, callback.text
|
|
code = query_of(callback.headers["location"])["code"]
|
|
tokens = exchange_code(client, registration, code, verifier)
|
|
assert tokens.status_code == 200, tokens.text
|
|
return SignIn(
|
|
client_id=registration["client_id"],
|
|
client_secret=registration["client_secret"],
|
|
verifier=verifier,
|
|
code=code,
|
|
tokens=tokens.json(),
|
|
gitea_url=gitea_url,
|
|
)
|
|
|
|
|
|
def exchange_code(
|
|
client: TestClient, registration: dict[str, Any], code: str, verifier: str
|
|
) -> Any:
|
|
return client.post(
|
|
"/token",
|
|
data={
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": REDIRECT,
|
|
"client_id": registration["client_id"],
|
|
"client_secret": registration["client_secret"],
|
|
"code_verifier": verifier,
|
|
},
|
|
)
|
|
|
|
|
|
def refresh(client: TestClient, flow: SignIn, refresh_token: str, **extra: str) -> Any:
|
|
return client.post(
|
|
"/token",
|
|
data={
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": refresh_token,
|
|
"client_id": flow.client_id,
|
|
"client_secret": flow.client_secret,
|
|
**extra,
|
|
},
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def mcp_client(app: Starlette, token: str) -> AsyncIterator[Client]:
|
|
"""The SDK client calling ``app`` over an in-process ASGI transport with ``token``."""
|
|
async with (
|
|
app.router.lifespan_context(app),
|
|
httpx2.AsyncClient(
|
|
transport=httpx2.ASGITransport(app=app),
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
base_url=BASE,
|
|
) as http_client,
|
|
Client(streamable_http_client(f"{RESOURCE}", http_client=http_client)) as c,
|
|
):
|
|
yield c
|
|
|
|
|
|
def payload(result: Any) -> dict[str, Any]:
|
|
return json.loads(result.content[0].text) # type: ignore[no-any-return]
|
|
|
|
|
|
def form_of(request: httpx.Request) -> dict[str, str]:
|
|
"""The form-encoded body of a request Gitea received."""
|
|
return {key: values[0] for key, values in parse_qs(request.content.decode()).items()}
|
|
|
|
|
|
def gitea_requests(httpx_mock: HTTPXMock, url_prefix: str) -> list[httpx.Request]:
|
|
return [r for r in httpx_mock.get_requests() if str(r.url).startswith(url_prefix)]
|
|
|
|
|
|
def mcp_status_lifespan(app: Starlette, token: str) -> int:
|
|
"""HTTP status of a bare MCP request with ``token``, with the app's lifespan running."""
|
|
with browser(app) as client:
|
|
response = client.post("/mcp", json={}, headers={"Authorization": f"Bearer {token}"})
|
|
return response.status_code
|