Files
microdao-daarion/services/sofiia-console/app/auth.py
2026-03-02 09:42:10 -08:00

121 lines
3.7 KiB
Python

"""
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