""" Auth for Sofiia Console. Strategy: cookie-based session (httpOnly, Secure, SameSite=Lax). Login: POST /api/auth/login { "key": "..." } → sets console_token cookie Logout: POST /api/auth/logout → clears cookie All protected endpoints check: 1. Cookie "console_token" (browser sessions) 2. X-API-Key header (backward compat: curl, API clients) Dev mode (ENV != prod, no key configured): open access. """ import hashlib import logging import os import secrets from fastapi import Cookie, HTTPException, Request, Security from fastapi.security import APIKeyHeader logger = logging.getLogger(__name__) _IS_PROD = os.getenv("ENV", "dev").strip().lower() in ("prod", "production", "staging") _COOKIE_NAME = "console_token" _COOKIE_MAX_AGE = 60 * 60 * 24 * 7 # 7 days API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False) def get_console_api_key() -> str: return os.getenv("SOFIIA_CONSOLE_API_KEY", "").strip() def _key_valid(provided: str) -> bool: configured = get_console_api_key() if not configured: return True # no key set → open return secrets.compare_digest(provided.strip(), configured) def _cookie_token(api_key: str) -> str: """Derive a stable session token from the api key (so we never store key directly in cookie).""" return hashlib.sha256(api_key.encode()).hexdigest() def _expected_cookie_token() -> str: return _cookie_token(get_console_api_key()) def require_auth( request: Request, x_api_key: str = Security(API_KEY_HEADER), ) -> str: """ Check cookie OR X-API-Key header. Localhost (127.0.0.1 / ::1) is ALWAYS allowed — no key needed. In dev mode without a configured key: pass through. """ # Localhost bypass — always open for local development client_ip = (request.client.host if request.client else "") or "" if client_ip in ("127.0.0.1", "::1", "localhost"): return "localhost" configured = get_console_api_key() if not configured: if _IS_PROD: logger.warning("SOFIIA_CONSOLE_API_KEY not set in prod — console is OPEN") return "" # 1) Cookie check cookie_val = request.cookies.get(_COOKIE_NAME, "") if cookie_val and secrets.compare_digest(cookie_val, _expected_cookie_token()): return "cookie" # 2) X-API-Key header (for API clients / curl) if x_api_key and _key_valid(x_api_key): return "header" raise HTTPException(status_code=401, detail="Unauthorized") def require_auth_strict( request: Request, x_api_key: str = Security(API_KEY_HEADER), ) -> str: """Like require_auth but always fails if no key is configured.""" configured = get_console_api_key() if not configured: raise HTTPException(status_code=503, detail="SOFIIA_CONSOLE_API_KEY not configured") return require_auth(request, x_api_key) def require_audit_auth( request: Request, x_api_key: str = Security(API_KEY_HEADER), ) -> str: """ Strict auth for sensitive audit endpoint: - Requires configured SOFIIA_CONSOLE_API_KEY - Does NOT allow localhost bypass - Accepts cookie or X-API-Key when key is configured """ configured = get_console_api_key() if not configured: raise HTTPException(status_code=401, detail="Audit endpoint requires SOFIIA_CONSOLE_API_KEY") cookie_val = request.cookies.get(_COOKIE_NAME, "") if cookie_val and secrets.compare_digest(cookie_val, _expected_cookie_token()): return "cookie" if x_api_key and _key_valid(x_api_key): return "header" raise HTTPException(status_code=401, detail="Unauthorized") # Keep old names as aliases so existing imports don't break require_api_key = require_auth require_api_key_strict = require_auth_strict