mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-09 15:58:30 +00:00
feat(mcp): add per-request API key identity auth slice
This commit is contained in:
parent
fa7075fde6
commit
39fd7a2218
4 changed files with 129 additions and 0 deletions
7
surfsense_mcp/src/surfsense_mcp/core/auth/__init__.py
Normal file
7
surfsense_mcp/src/surfsense_mcp/core/auth/__init__.py
Normal 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"]
|
||||
26
surfsense_mcp/src/surfsense_mcp/core/auth/headers.py
Normal file
26
surfsense_mcp/src/surfsense_mcp/core/auth/headers.py
Normal 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
|
||||
42
surfsense_mcp/src/surfsense_mcp/core/auth/identity.py
Normal file
42
surfsense_mcp/src/surfsense_mcp/core/auth/identity.py
Normal 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
|
||||
54
surfsense_mcp/src/surfsense_mcp/core/auth/middleware.py
Normal file
54
surfsense_mcp/src/surfsense_mcp/core/auth/middleware.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue