"""Optional API key auth and in-memory rate limiting for the REST server.""" from __future__ import annotations import os import time from collections import defaultdict from fastapi import HTTPException, Request, Security from fastapi.security import APIKeyHeader from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_429_TOO_MANY_REQUESTS from services.config_loader import load_config _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) def configured_api_key() -> str | None: """API key from env (preferred) or config. Never commit real keys.""" key = os.environ.get("FORECASTER_API_KEY", "").strip() if key: return key cfg_key = load_config().get("api", {}).get("api_key") return str(cfg_key).strip() if cfg_key else None async def verify_api_key( api_key: str | None = Security(_api_key_header), ) -> None: """Enforce X-API-Key when FORECASTER_API_KEY is set; open in local dev otherwise.""" expected = configured_api_key() if not expected: return if not api_key or api_key != expected: raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, detail="Invalid or missing X-API-Key header", ) class RateLimiter: """Simple per-client-IP sliding window (in-memory, single process).""" def __init__(self, requests_per_minute: int = 60): self.limit = max(1, requests_per_minute) self._hits: dict[str, list[float]] = defaultdict(list) def _client_id(self, request: Request) -> str: forwarded = request.headers.get("X-Forwarded-For") if forwarded: return forwarded.split(",")[0].strip() if request.client: return request.client.host return "unknown" def check(self, request: Request) -> None: now = time.monotonic() window = 60.0 cid = self._client_id(request) hits = [t for t in self._hits[cid] if now - t < window] if len(hits) >= self.limit: raise HTTPException( status_code=HTTP_429_TOO_MANY_REQUESTS, detail=f"Rate limit exceeded ({self.limit} requests/minute)", ) hits.append(now) self._hits[cid] = hits