import hashlib import hmac import os import re import secrets import threading from ipaddress import ip_address, ip_network from time import monotonic from fastapi import Header, HTTPException, Request from .config import settings OPERATOR_SESSION_COOKIE = "appops_operator_session" OPERATOR_SESSION_CSRF_HEADER = "X-AppOps-Session-CSRF" _OPERATOR_SESSION_TTL_SECONDS = 8 * 60 * 60 _operator_sessions: dict[str, tuple[float, str]] = {} _operator_sessions_lock = threading.Lock() def _operator_token() -> str | None: expected = settings.operator_token or os.getenv("APPOPS_OPERATOR_TOKEN") token_file = settings.operator_token_file or os.getenv("APPOPS_OPERATOR_TOKEN_FILE") if expected or not token_file: return expected try: with open(token_file, encoding="utf-8") as handle: return handle.read().strip() or None except OSError: return None def token_is_strong(value: str | None, *, minimum_length: int = 24) -> bool: if not value or len(value) < minimum_length: return False return value.strip().lower() not in {"change-me", "changeme", "dev", "test", "dev-voice-token"} def _extract_bearer_token(authorization: str | None) -> str: if not authorization: raise HTTPException(status_code=401, detail="Authorization header missing") prefix = "Bearer " if not authorization.startswith(prefix): raise HTTPException(status_code=401, detail="Authorization header missing") return authorization[len(prefix):] def require_auth_token(provided_token: str | None, expected_token: str | None, scope: str): if expected_token: token = _extract_bearer_token(provided_token) if not hmac.compare_digest(token, expected_token): raise HTTPException(status_code=403, detail=f"Invalid {scope} token") def _operator_token_from_request(request: Request) -> str | None: explicit = request.headers.get("X-AppOps-Operator-Token") if explicit: return explicit.strip() authorization = request.headers.get("Authorization") if authorization and authorization.startswith("Bearer "): return authorization[len("Bearer "):].strip() return None def _operator_token_fingerprint(value: str | None) -> str: return hashlib.sha256((value or "").encode("utf-8")).hexdigest() def _remove_expired_operator_sessions(now: float) -> None: expired = [session_id for session_id, (expires_at, _) in _operator_sessions.items() if expires_at <= now] for session_id in expired: _operator_sessions.pop(session_id, None) def issue_operator_session(request: Request) -> str: """Exchange the explicit operator token for an opaque, process-local browser session.""" expected = _operator_token() provided = _operator_token_from_request(request) auth_required = settings.operator_auth_required if not auth_required: raise HTTPException(status_code=409, detail="Operator sessions are disabled") if not provided: raise HTTPException(status_code=401, detail="Operator token missing") if not expected: if auth_required: raise HTTPException(status_code=503, detail="Operator authentication is not configured") elif settings.environment == "production" and not token_is_strong(expected): raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token") elif not hmac.compare_digest(provided, expected): raise HTTPException(status_code=403, detail="Invalid operator token") session_id = secrets.token_urlsafe(32) now = monotonic() with _operator_sessions_lock: _remove_expired_operator_sessions(now) _operator_sessions[session_id] = ( now + _OPERATOR_SESSION_TTL_SECONDS, _operator_token_fingerprint(expected), ) return session_id def revoke_operator_session(request: Request) -> None: session_id = request.cookies.get(OPERATOR_SESSION_COOKIE, "") if not session_id: return with _operator_sessions_lock: _operator_sessions.pop(session_id, None) def _operator_session_identity(request: Request, expected: str | None) -> str | None: session_id = request.cookies.get(OPERATOR_SESSION_COOKIE, "") if not session_id: return None now = monotonic() with _operator_sessions_lock: _remove_expired_operator_sessions(now) record = _operator_sessions.get(session_id) if record and not hmac.compare_digest(record[1], _operator_token_fingerprint(expected)): _operator_sessions.pop(session_id, None) record = None if not record: return None if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}: csrf = request.headers.get(OPERATOR_SESSION_CSRF_HEADER, "") if not hmac.compare_digest(csrf, "1"): raise HTTPException(status_code=403, detail="Operator session CSRF validation failed") return "session" def _normalized_set(values: list[str] | None) -> set[str]: return {value.strip().casefold() for value in values or [] if value.strip()} def _request_from_trusted_operator_proxy(request: Request) -> bool: if not settings.operator_proxy_auth_enabled or not settings.operator_proxy_trusted_networks: return False client_host = request.client.host if request.client else "" try: client_ip = ip_address(client_host) except ValueError: return False for configured_network in settings.operator_proxy_trusted_networks: try: if client_ip in ip_network(configured_network, strict=False): return True except ValueError: continue return False def _trusted_proxy_operator_identity(request: Request) -> str | None: """Resolve an Authentik identity only across an explicitly trusted proxy boundary.""" if not _request_from_trusted_operator_proxy(request): return None allowed_users = _normalized_set(settings.operator_proxy_users) allowed_groups = _normalized_set(settings.operator_proxy_groups) if not allowed_users and not allowed_groups: return None username = request.headers.get("X-authentik-username", "").strip() groups_header = request.headers.get("X-authentik-groups", "") groups = { item.strip().casefold() for item in re.split(r"[|,;]", groups_header) if item.strip() } if username and (username.casefold() in allowed_users or groups.intersection(allowed_groups)): return username return None def require_operator_request(request: Request) -> str: """Validate the local operator boundary without leaking token material.""" expected = _operator_token() auth_required = settings.operator_auth_required if not auth_required: return "private_unlocked" proxy_identity = _trusted_proxy_operator_identity(request) if proxy_identity: return f"authentik:{proxy_identity}" provided = _operator_token_from_request(request) if provided: if not expected: raise HTTPException(status_code=503, detail="Operator authentication is not configured") if settings.environment == "production" and not token_is_strong(expected): raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token") if not hmac.compare_digest(provided, expected): raise HTTPException(status_code=403, detail="Invalid operator token") return "operator" session_identity = _operator_session_identity(request, expected) if session_identity: return session_identity if not expected: raise HTTPException(status_code=503, detail="Operator authentication is not configured") if settings.environment == "production" and not token_is_strong(expected): raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token") raise HTTPException(status_code=401, detail="Operator authorization missing") def operator_session_status(request: Request) -> dict[str, str | bool | None]: """Return the current request's operator state without exposing credentials.""" try: identity = require_operator_request(request) except HTTPException: return {"authorized": False, "mode": "locked", "identity": None} if identity.startswith("authentik:"): return {"authorized": True, "mode": "account", "identity": identity.split(":", 1)[1]} if identity == "session": return {"authorized": True, "mode": "session", "identity": None} return {"authorized": True, "mode": "token" if identity == "operator" else "private", "identity": None} def require_service_request(request: Request, service: str) -> str: """Authenticate a narrowly scoped machine integration without reusing operator credentials.""" if service == "mcp": enabled, expected = settings.mcp_enabled, settings.mcp_token elif service == "n8n": enabled, expected = settings.n8n_enabled, settings.n8n_token else: raise HTTPException(status_code=403, detail="Unknown service identity") if not enabled: raise HTTPException(status_code=404, detail=f"{service} integration is disabled") if not token_is_strong(expected): raise HTTPException(status_code=503, detail=f"{service} authentication is not safely configured") provided = _operator_token_from_request(request) if not provided: raise HTTPException(status_code=401, detail=f"{service} authorization missing") if not hmac.compare_digest(provided, expected): raise HTTPException(status_code=403, detail=f"Invalid {service} token") return service def n8n_auth(request: Request) -> str: return require_service_request(request, "n8n") def operator_auth(request: Request) -> str: return require_operator_request(request) def voice_auth(authorization: str | None = Header(default=None, alias='Authorization')): expected = settings.voice_token or os.getenv("APP_OPS_VOICE_TOKEN") if not expected: return True require_auth_token(authorization, expected, "voice") return True