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.
170 lines
6.3 KiB
Python
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)
|