Files
Stirling-PDF/engine/src/stirling/api/engine_auth.py
T
Anthony StirlingandGitHub 3ecd95b779 Add MCP server with OAuth/API-key auth (#6570)
Adds an optional MCP server (proprietary module) that exposes Stirling's
PDF operations and AI capabilities to MCP clients. Off by default, zero
footprint when disabled.

### What
- New `/mcp` endpoint: streamable-HTTP + JSON-RPC 2.0; 8 tools
(describe_operation, pages/convert/misc/security category tools, AI,
upload, download).
- Runs real operations over an internal loopback; results returned
inline as base64 (small) or by fileId (large).

### Auth (two modes)
- OAuth2 resource server: RFC 9728 protected-resource metadata, RFC 8707
audience binding, JWKS, `mcp.tools.read/write` scopes; binds each token
to a provisioned Stirling account.
- API-key mode: reuses Stirling per-user `X-API-KEY` (no IdP needed).

### Security
- Per-user file ownership in FileStorage: async/queued writes scoped to
the submitting user; legacy/owner-less files stay readable.
- Admin allow/block list controls which operations are exposed.
- Python engine gated behind a shared secret (`X-Engine-Auth`).
- MCP filter chain is isolated and cannot weaken the main app's
security.
- Hardened: no upstream error-body leakage, log injection sanitized,
fileId path/sidecar enumeration blocked.

### Config / footprint
- Disabled by default (`mcp.enabled=false`); all beans
`@ConditionalOnProperty`.
---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-10 09:46:25 +00:00

100 lines
3.9 KiB
Python

"""
Shared-secret middleware that locks the engine to the trusted Java backend.
Config (resolved via :class:`stirling.config.AppSettings`/pydantic-settings):
``STIRLING_ENGINE_SHARED_SECRET`` - non-public routes need ``X-Engine-Auth`` or 401.
``STIRLING_ENGINE_REQUIRE_AUTH`` - fail closed with 503 when truthy and no secret is set.
"""
from __future__ import annotations
import hmac
import logging
from collections.abc import Iterable
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.types import ASGIApp
from stirling.config import load_settings
logger = logging.getLogger(__name__)
_HEADER = "X-Engine-Auth"
# Public paths (liveness + docs); everything else needs the secret when configured.
_PUBLIC_PREFIXES: tuple[str, ...] = (
"/health",
"/docs",
"/redoc",
"/openapi.json",
)
class EngineSharedSecretMiddleware(BaseHTTPMiddleware):
"""Reject non-public requests lacking the shared secret.
Non-public path: secret set -> require matching X-Engine-Auth (else 401); else require flag
truthy -> 503 (fail-closed); else allow through.
Secret/require values come from :class:`stirling.config.AppSettings` by default; tests can
pass them explicitly to avoid touching the lru-cached settings.
"""
def __init__(
self,
app: ASGIApp,
public_prefixes: Iterable[str] = _PUBLIC_PREFIXES,
*,
secret: str | None = None,
require: bool | None = None,
) -> None:
super().__init__(app)
self._public_prefixes = tuple(public_prefixes)
if secret is None or require is None:
settings = load_settings()
if secret is None:
secret = settings.engine_shared_secret
if require is None:
require = settings.engine_require_auth
self._secret = secret or ""
self._require = bool(require)
if self._secret:
logger.info(
"Engine shared-secret enforcement ENABLED: non-public routes require a valid %s"
" header (constant-time compared).",
_HEADER,
)
elif self._require:
logger.error(
"STIRLING_ENGINE_REQUIRE_AUTH is enabled but STIRLING_ENGINE_SHARED_SECRET is not"
" set - the engine will REFUSE every non-public request (HTTP 503, fail-closed)"
" until a shared secret is configured.",
)
else:
logger.warning(
"STIRLING_ENGINE_SHARED_SECRET not set - engine shared-secret enforcement is"
" DISABLED. The AI and document routes then trust the caller-supplied X-User-Id"
" header alone. Set this secret (and STIRLING_ENGINE_REQUIRE_AUTH=true) in any"
" deployment that exposes the engine beyond localhost.",
)
def _is_public(self, path: str) -> bool:
return any(path == p or path.startswith(p + "/") for p in self._public_prefixes)
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if not self._is_public(request.url.path):
if self._secret:
offered = request.headers.get(_HEADER) or ""
# Constant-time compare to avoid timing leaks.
if not hmac.compare_digest(offered, self._secret):
return JSONResponse({"detail": "Missing or invalid X-Engine-Auth header."}, status_code=401)
elif self._require:
# Fail closed: require flag set but no secret configured.
return JSONResponse(
{"detail": ("Engine authentication is required but no shared secret is configured.")},
status_code=503,
)
return await call_next(request)