diff --git a/surfsense_mcp/src/surfsense_mcp/core/auth/__init__.py b/surfsense_mcp/src/surfsense_mcp/core/auth/__init__.py new file mode 100644 index 000000000..e837c6d61 --- /dev/null +++ b/surfsense_mcp/src/surfsense_mcp/core/auth/__init__.py @@ -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"] diff --git a/surfsense_mcp/src/surfsense_mcp/core/auth/headers.py b/surfsense_mcp/src/surfsense_mcp/core/auth/headers.py new file mode 100644 index 000000000..c06989909 --- /dev/null +++ b/surfsense_mcp/src/surfsense_mcp/core/auth/headers.py @@ -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 diff --git a/surfsense_mcp/src/surfsense_mcp/core/auth/identity.py b/surfsense_mcp/src/surfsense_mcp/core/auth/identity.py new file mode 100644 index 000000000..5f242e94e --- /dev/null +++ b/surfsense_mcp/src/surfsense_mcp/core/auth/identity.py @@ -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 diff --git a/surfsense_mcp/src/surfsense_mcp/core/auth/middleware.py b/surfsense_mcp/src/surfsense_mcp/core/auth/middleware.py new file mode 100644 index 000000000..2f2356ad7 --- /dev/null +++ b/surfsense_mcp/src/surfsense_mcp/core/auth/middleware.py @@ -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, + )