115 lines
4.1 KiB
Python
115 lines
4.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Prove one Fleet Ops demo-token MCP call, then prove its revocation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from scripts.revoke_demo_token import revoke
|
|
from services.runtime.demo_token import load_demo_token_signing_key, mint_demo_token
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HttpResult:
|
|
status: int
|
|
content_type: str
|
|
body: bytes
|
|
|
|
|
|
def post_tool_call(endpoint: str, token: str, *, timeout_seconds: float) -> HttpResult:
|
|
request = urllib.request.Request( # noqa: S310 - operator supplies an explicit HTTP(S) MCP endpoint
|
|
endpoint,
|
|
data=json.dumps(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": "demo-token-flow-verification",
|
|
"method": "tools/call",
|
|
"params": {"name": "mobilityops.operations.summary", "arguments": {}},
|
|
},
|
|
separators=(",", ":"),
|
|
).encode("utf-8"),
|
|
headers={
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
"MCP-Protocol-Version": "2025-11-25",
|
|
},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout_seconds) as response: # noqa: S310
|
|
return HttpResult(response.status, response.headers.get_content_type(), response.read())
|
|
except urllib.error.HTTPError as error:
|
|
try:
|
|
body = error.read()
|
|
except (OSError, ConnectionError):
|
|
# The status/header proof remains authoritative when the peer closes
|
|
# the optional error-body stream immediately after sending its 401.
|
|
body = b""
|
|
return HttpResult(error.code, error.headers.get_content_type(), body)
|
|
|
|
|
|
def verify_flow(
|
|
endpoint: str,
|
|
key_file: Path,
|
|
revocations_file: Path,
|
|
*,
|
|
timeout_seconds: float = 15.0,
|
|
) -> tuple[HttpResult, HttpResult]:
|
|
"""Run a bounded live proof and revoke its generated bearer in all cases."""
|
|
|
|
key = load_demo_token_signing_key(key_file)
|
|
token, jti = mint_demo_token(key, ttl_hours=1)
|
|
try:
|
|
valid = post_tool_call(endpoint, token, timeout_seconds=timeout_seconds)
|
|
if valid.status != 200:
|
|
raise RuntimeError(f"valid demo-token call returned HTTP {valid.status}")
|
|
if valid.content_type != "application/json":
|
|
raise RuntimeError("valid demo-token call did not return application/json")
|
|
try:
|
|
document: Any = json.loads(valid.body)
|
|
except (UnicodeDecodeError, ValueError) as exc:
|
|
raise RuntimeError("valid demo-token call returned invalid JSON") from exc
|
|
if not isinstance(document, dict):
|
|
raise RuntimeError("valid demo-token call returned a non-object JSON payload")
|
|
finally:
|
|
revoke(revocations_file, jti)
|
|
|
|
revoked = post_tool_call(endpoint, token, timeout_seconds=timeout_seconds)
|
|
if revoked.status != 401:
|
|
raise RuntimeError(f"revoked demo-token call returned HTTP {revoked.status}, expected 401")
|
|
return valid, revoked
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--endpoint", required=True)
|
|
parser.add_argument("--key-file", type=Path, required=True)
|
|
parser.add_argument("--revocations-file", type=Path, required=True)
|
|
parser.add_argument("--timeout-seconds", type=float, default=15.0)
|
|
args = parser.parse_args()
|
|
if not 1 <= args.timeout_seconds <= 30:
|
|
raise ValueError("timeout-seconds must be between 1 and 30")
|
|
valid, revoked = verify_flow(
|
|
args.endpoint,
|
|
args.key_file,
|
|
args.revocations_file,
|
|
timeout_seconds=args.timeout_seconds,
|
|
)
|
|
print(f"valid_call_http={valid.status}")
|
|
print(f"valid_call_content_type={valid.content_type}")
|
|
print(f"valid_call_bytes={len(valid.body)}")
|
|
print(f"revoked_call_http={revoked.status}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|