"""Middleware that authenticates every request and pins ``request.state.tenant_id``. Identity is resolved from a signed bearer token (``Authorization: Bearer ``) whose subject is the tenant_id. Because the token is HMAC-signed server-side, a client cannot claim another tenant's data — this is what enforces isolation. For local development (``DEV_MODE=true``) a raw ``X-Tenant-ID`` header is still accepted as a fallback so existing tooling and tests keep working. In production a valid token is mandatory. """ from collections.abc import Awaitable, Callable from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import JSONResponse, Response from app.api.auth import verify_token from app.config import settings # Paths that do not require authentication (exact match) _EXEMPT: frozenset[str] = frozenset( { "/", "/health", "/docs", "/openapi.json", "/redoc", "/favicon.ico", "/auth/login", "/auth/register", } ) # Path prefixes that do not require authentication _EXEMPT_PREFIXES: tuple[str, ...] = ("/static/",) class TenantAuthMiddleware(BaseHTTPMiddleware): """Authenticate the caller and expose the verified tenant on request state. Example:: # Obtain a token, then call protected endpoints with it: curl -H "Authorization: Bearer " http://localhost:8000/documents """ async def dispatch( self, request: Request, call_next: Callable[[Request], Awaitable[Response]], ) -> Response: path = request.url.path if path in _EXEMPT or path.startswith(_EXEMPT_PREFIXES): return await call_next(request) tenant_id = self._resolve_tenant(request) if tenant_id is None: return JSONResponse( status_code=401, content={"detail": "Authentication required. Log in to obtain a token."}, ) request.state.tenant_id = tenant_id return await call_next(request) @staticmethod def _resolve_tenant(request: Request) -> str | None: auth = request.headers.get("Authorization", "") if auth.lower().startswith("bearer "): token = auth[7:].strip() sub = verify_token(token) if sub: return sub # A present-but-invalid token must never silently fall through to # the dev header path. return None # ```` and direct links cannot send Authorization headers; the UI # passes the same signed token as ``access_token`` (scoped to this origin). query_token = (request.query_params.get("access_token") or "").strip() if query_token: sub = verify_token(query_token) if sub: return sub return None if settings.dev_mode: raw = request.headers.get("X-Tenant-ID") if raw and raw.strip(): return raw.strip() return None