test: cover the per-user OAuth flow, threat cases, store and startup
This commit is contained in:
1 parent
566d0b65dd
commit
7286440d64
9 files changed
+2505
-7
No files matched your search
@@ -0,0 +1,303 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user