"""Static API-key authentication for the sms façade. Clients present a key in one of two headers: Authorization: Bearer X-API-Key: The server stores only the **SHA-256 hex hash(es)** of accepted keys in the ``SMS_API_KEY_HASH`` setting (comma-separated). The raw key is never persisted; losing it means generating a new one and updating the hash. Fail-closed: if no hash is configured, every protected route returns 503 rather than silently admitting traffic. ``/health`` is exempt (see ``app.py``). """ from __future__ import annotations import hashlib import logging import secrets from fastapi import HTTPException, Request, status logger = logging.getLogger("voipms.sms.auth") def _hash_key(raw: str) -> str: return hashlib.sha256(raw.encode("utf-8")).hexdigest() def _allowed_hashes(request: Request) -> set[str]: raw = request.app.state.settings.sms_api_key_hash or "" return {h.strip().lower() for h in raw.split(",") if h.strip()} def _extract_token(request: Request) -> str | None: auth = request.headers.get("authorization") if auth: parts = auth.split(None, 1) if len(parts) == 2 and parts[0].lower() == "bearer": return parts[1].strip() or None return None return request.headers.get("x-api-key") def verify_api_key(request: Request) -> None: """FastAPI dependency gating every protected route.""" allowed = _allowed_hashes(request) if not allowed: logger.error("SMS_API_KEY_HASH not configured; refusing request (fail-closed).") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="api key auth not configured", ) token = _extract_token(request) if not token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="missing api key", headers={"WWW-Authenticate": "Bearer"}, ) digest = _hash_key(token) if not any(secrets.compare_digest(digest, h) for h in allowed): logger.warning("rejected api key from %s", request.client.host if request.client else "?") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid api key", headers={"WWW-Authenticate": "Bearer"}, )