feat(mcp): add per-request API key identity auth slice

This commit is contained in:
CREDO23 2026-07-07 16:52:30 +02:00
parent fa7075fde6
commit 39fd7a2218
4 changed files with 129 additions and 0 deletions

View file

@ -0,0 +1,7 @@
"""Per-request caller identity: header parsing, request-scoped storage, and the
ASGI middleware that binds them together for the remote transport."""
from .identity import current_api_key, current_identity
from .middleware import ApiKeyIdentityMiddleware
__all__ = ["current_api_key", "current_identity", "ApiKeyIdentityMiddleware"]

View file

@ -0,0 +1,26 @@
"""Extraction of a SurfSense API key from request headers.
Pure and side-effect free: given the request headers, return the caller's key
or ``None``. Isolated from transport and state so the parsing rules stay
trivially unit-testable.
"""
from __future__ import annotations
from starlette.datastructures import Headers
_BEARER_PREFIX = "bearer "
def extract_api_key(headers: Headers) -> str | None:
"""Return the caller's key from the ``Authorization: Bearer`` slot the
backend already expects, falling back to ``X-API-Key`` for clients that can
only send custom headers."""
authorization = headers.get("authorization", "")
if authorization[: len(_BEARER_PREFIX)].lower() == _BEARER_PREFIX:
token = authorization[len(_BEARER_PREFIX) :].strip()
if token:
return token
fallback = headers.get("x-api-key", "").strip()
return fallback or None

View file

@ -0,0 +1,42 @@
"""Request-scoped caller identity.
Over streamable-http one process serves many users, so the caller's key lives
in a contextvar for the life of a single request: the auth middleware binds it,
and the client reads it when building the outbound backend call. Under stdio
there is no request, the contextvar stays empty, and the env key is used.
The contextvar is request-scoped, not stored state it is re-derived from the
header on every request, which is what keeps the server stateless.
"""
from __future__ import annotations
from contextvars import ContextVar, Token
_LOCAL_IDENTITY = "__local__"
_api_key: ContextVar[str | None] = ContextVar("surfsense_api_key", default=None)
def bind_api_key(api_key: str | None) -> Token:
"""Bind the caller's key to the current request; returns a reset token."""
return _api_key.set(api_key)
def unbind_api_key(token: Token) -> None:
"""Release the binding once the request is done."""
_api_key.reset(token)
def current_api_key() -> str | None:
"""The caller's key for the in-flight request, or ``None`` under stdio."""
return _api_key.get()
def current_identity() -> str:
"""Stable per-caller key for scoping request state.
The token identifies the account, so state keyed on it is naturally
per-user and survives reconnects. Under stdio all calls share one identity.
"""
return _api_key.get() or _LOCAL_IDENTITY

View file

@ -0,0 +1,54 @@
"""ASGI middleware that establishes the caller's identity for each request.
A pure ASGI middleware, deliberately not Starlette's ``BaseHTTPMiddleware``:
the latter runs the endpoint in a separate task, so a contextvar set in it does
not reach the tool handler. A pure middleware sets the key in the request's own
task, from which the SDK's per-request handling inherits it (verified).
Requests without a key are rejected here so no tool ever runs unauthenticated.
"""
from __future__ import annotations
from starlette.datastructures import Headers
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Receive, Scope, Send
from .headers import extract_api_key
from .identity import bind_api_key, unbind_api_key
class ApiKeyIdentityMiddleware:
"""Binds the per-request API key into the identity contextvar, or 401s."""
def __init__(self, app: ASGIApp) -> None:
self._app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self._app(scope, receive, send)
return
api_key = extract_api_key(Headers(scope=scope))
if api_key is None:
await _unauthenticated()(scope, receive, send)
return
token = bind_api_key(api_key)
try:
await self._app(scope, receive, send)
finally:
unbind_api_key(token)
def _unauthenticated() -> JSONResponse:
return JSONResponse(
{
"error": "unauthorized",
"message": (
"Missing SurfSense API key. Send 'Authorization: Bearer "
"ss_pat_...' (or an X-API-Key header)."
),
},
status_code=401,
)