mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-10 22:32:16 +02:00
feat(mcp): add per-request API key identity auth slice
This commit is contained in:
parent
b477d33cba
commit
97f6497e2a
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