127 lines
4.8 KiB
Python
127 lines
4.8 KiB
Python
"""
|
|
AuthGateMiddleware - the single, fail-closed authentication boundary.
|
|
|
|
The previous design decided auth in a per-route FastAPI dependency that, when
|
|
`jwt_enabled` was false (the default), returned `lambda: None` - so every
|
|
`Depends(token_dep)` was decorative and the whole API was open. Static mounts,
|
|
the MCP transports and the Prometheus endpoint were never covered at all.
|
|
|
|
This middleware moves auth to the outermost ASGI layer so it covers EVERY
|
|
route, mount and sub-app (HTTP + WebSocket) uniformly, and it fails closed: a
|
|
request without a valid credential is rejected before it reaches any handler.
|
|
|
|
Accepted credentials:
|
|
* the static operator API token (constant-time compared) -> admin scope, or
|
|
* a valid HS256 JWT minted by this server -> the token's own scope claim.
|
|
|
|
Public paths (the health check and the token-issuing endpoint) pass through.
|
|
Public prefixes (the UI static shells) also pass through - they serve no data.
|
|
On failure: HTTP 401 JSON, or WebSocket close 4401.
|
|
On success: the validated principal is attached at scope["state"]["principal"]
|
|
(readable downstream as request.state.principal) for scope/ownership checks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Callable, Dict, Iterable, Optional
|
|
from urllib.parse import parse_qs
|
|
|
|
import jwt
|
|
|
|
from auth import constant_time_eq, decode_token
|
|
|
|
|
|
class AuthGateMiddleware:
|
|
def __init__(
|
|
self,
|
|
app,
|
|
*,
|
|
token_provider: Callable[[], str],
|
|
public_paths: Iterable[str] = (),
|
|
public_prefixes: Iterable[str] = (),
|
|
):
|
|
self.app = app
|
|
self._token_provider = token_provider
|
|
self.public_paths = set(public_paths)
|
|
self.public_prefixes = tuple(public_prefixes)
|
|
|
|
# ─────────────────────────── ASGI entry ───────────────────────────
|
|
async def __call__(self, scope, receive, send):
|
|
if scope["type"] not in ("http", "websocket"):
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
path = scope.get("path", "")
|
|
if path in self.public_paths:
|
|
await self.app(scope, receive, send)
|
|
return
|
|
if self.public_prefixes and path.startswith(self.public_prefixes):
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
principal = self._authenticate(scope)
|
|
if principal is None:
|
|
await self._reject(scope, receive, send)
|
|
return
|
|
|
|
# Expose the principal to downstream handlers/dependencies.
|
|
state = scope.setdefault("state", {})
|
|
state["principal"] = principal
|
|
await self.app(scope, receive, send)
|
|
|
|
# ──────────────────────────── helpers ─────────────────────────────
|
|
def _authenticate(self, scope) -> Optional[Dict]:
|
|
token = self._extract_token(scope)
|
|
if not token:
|
|
return None
|
|
|
|
# 1) static operator token -> admin scope
|
|
static_token = self._token_provider() or ""
|
|
if static_token and constant_time_eq(token, static_token):
|
|
return {"sub": "operator", "scope": "admin", "via": "api_token"}
|
|
|
|
# 2) HS256 JWT minted by this server
|
|
try:
|
|
claims = decode_token(token)
|
|
except jwt.InvalidTokenError:
|
|
return None
|
|
claims.setdefault("scope", "data")
|
|
return claims
|
|
|
|
@staticmethod
|
|
def _extract_token(scope) -> Optional[str]:
|
|
# Authorization: Bearer <token>
|
|
for name, value in scope.get("headers", []):
|
|
if name == b"authorization":
|
|
raw = value.decode("latin-1")
|
|
if raw[:7].lower() == "bearer ":
|
|
return raw[7:].strip()
|
|
return None
|
|
# WebSocket clients that cannot set headers may pass ?token=
|
|
if scope["type"] == "websocket":
|
|
qs = parse_qs(scope.get("query_string", b"").decode("latin-1"))
|
|
vals = qs.get("token")
|
|
if vals:
|
|
return vals[0]
|
|
return None
|
|
|
|
async def _reject(self, scope, receive, send):
|
|
if scope["type"] == "websocket":
|
|
# Close before accept; 4401 = application "unauthorized".
|
|
await send({"type": "websocket.close", "code": 4401})
|
|
return
|
|
body = json.dumps({"detail": "Authentication required"}).encode()
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": 401,
|
|
"headers": [
|
|
(b"content-type", b"application/json"),
|
|
(b"www-authenticate", b"Bearer"),
|
|
(b"content-length", str(len(body)).encode()),
|
|
],
|
|
}
|
|
)
|
|
await send({"type": "http.response.body", "body": body})
|