Files
next-tjwater-server/app/mcp/auth.py
T
jiang be6317eb48 feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence,
limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs.
Default nearby searches to 200 meters and five results.

Integrate investigation messaging/presentation, health/readiness and typed history;
remove duplicate web search and fix online simulation and compressed baseline writes.
Include project migrations 023-025; keep local credentials and demo seeding excluded.

Validation: 319 focused pytest checks; real PostGIS temporary-table regression;
read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
2026-10-19 18:46:58 +08:00

170 lines
6.3 KiB
Python

"""Verify resource-bound Keycloak tokens without passing them to business APIs."""
import asyncio
import time
from uuid import UUID
import httpx
from jose import JWTError, jwt
from mcp.server.auth.provider import AccessToken
from starlette.responses import JSONResponse
from app.mcp.config import McpSettings
CATALOG_SCOPE = "mcp.catalog.read"
QUERY_SCOPE = "mcp.query"
CONTEXT_SCOPE = "mcp.context.bind"
class McpTokenVerifier:
def __init__(self, settings: McpSettings, client: httpx.AsyncClient | None = None):
self.settings = settings
self.client = client
self._keys: list[dict] = []
self._expires = 0.0
self._last_refresh = 0.0
self._lock = asyncio.Lock()
async def _load_keys(self, kid: str) -> list[dict]:
now = time.monotonic()
if self._expires > now and any(k.get("kid") == kid for k in self._keys):
return self._keys
async with self._lock:
now = time.monotonic()
if self._expires > now and any(k.get("kid") == kid for k in self._keys):
return self._keys
if self._expires > now and now - self._last_refresh < 20:
return self._keys
url = (
self.settings.issuer_uri.rstrip("/") + "/protocol/openid-connect/certs"
)
self._last_refresh = now
if self.client:
response = await self.client.get(url)
else:
async with httpx.AsyncClient(
timeout=5, trust_env=False, follow_redirects=False
) as client:
response = await client.get(url)
response.raise_for_status()
try:
payload = response.json()
except ValueError:
raise httpx.RequestError("invalid OAuth key set response") from None
keys = payload.get("keys") if isinstance(payload, dict) else None
if not isinstance(keys, list) or len(keys) > 100:
raise httpx.RequestError("invalid OAuth key set")
self._keys = [
k
for k in keys
if isinstance(k, dict)
and k.get("kty") == "RSA"
and k.get("use", "sig") == "sig"
and k.get("alg", "RS256") == "RS256"
]
self._expires = now + 300
return self._keys
async def verify_token(self, token: str) -> AccessToken | None:
if not token or len(token) > 16384:
return None
try:
header = jwt.get_unverified_header(token)
kid = header.get("kid")
if (
header.get("alg") != "RS256"
or not isinstance(kid, str)
or not 0 < len(kid) <= 128
):
return None
keys = await self._load_keys(kid)
key = next((k for k in keys if k.get("kid") == kid), None)
if key is None:
return None
claims = jwt.decode(
token,
key,
algorithms=["RS256"],
audience=self.settings.audience,
issuer=self.settings.issuer_uri,
options={"require_exp": True, "require_iat": True, "require_sub": True},
)
UUID(claims["sub"])
client_id = claims.get("azp")
if not isinstance(client_id, str) or not client_id:
return None
if (
not isinstance(claims.get("iat"), (int, float))
or not time.time() - 900 < claims["iat"] <= time.time() + 30
or claims["exp"] > claims["iat"] + 900
):
return None
return AccessToken(
token=token,
client_id=client_id,
subject=claims["sub"],
scopes=str(claims.get("scope", "")).split(),
expires_at=int(claims["exp"]),
resource=self.settings.resource_uri,
claims=claims,
)
except (JWTError, ValueError, KeyError, TypeError):
return None
# JWKS/network failures propagate as unavailable, never as empty data.
def challenge(
settings: McpSettings, scope: str, *, insufficient: bool = False
) -> dict[str, str]:
from mcp.server.auth.routes import build_resource_metadata_url
metadata = build_resource_metadata_url(settings.resource_uri)
suffix = ', error="insufficient_scope"' if insufficient else ""
return {
"WWW-Authenticate": f'Bearer resource_metadata="{metadata}", scope="{scope}"{suffix}'
}
class QueryScopeMiddleware:
"""HTTP scope challenges before JSON-RPC dispatch; SDK validates mirrored headers."""
def __init__(self, app, verifier: McpTokenVerifier, settings: McpSettings):
self.app, self.verifier, self.settings = app, verifier, settings
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or scope.get("path") != "/mcp":
return await self.app(scope, receive, send)
headers = {
k.decode("latin1").lower(): v.decode("latin1")
for k, v in scope.get("headers", [])
}
auth = headers.get("authorization", "")
try:
token = (
await self.verifier.verify_token(auth[7:])
if auth.lower().startswith("bearer ")
else None
)
except (httpx.HTTPError, ValueError):
return await JSONResponse(
{"error": "authentication_unavailable"}, status_code=503
)(scope, receive, send)
required = (
QUERY_SCOPE
if headers.get("mcp-method") in {"tools/call", "resources/read"}
else CATALOG_SCOPE
)
if token is None:
return await JSONResponse(
{"error": "invalid_token"},
status_code=401,
headers=challenge(self.settings, required),
)(scope, receive, send)
if required not in token.scopes:
return await JSONResponse(
{"error": "insufficient_scope"},
status_code=403,
headers=challenge(self.settings, required, insufficient=True),
)(scope, receive, send)
await self.app(scope, receive, send)