"""Supabase JWT verification — same identity plane as the validator engine. The ABM engine is a product backend: the site attaches the user's Supabase session JWT (Authorization: Bearer) and this module verifies it against the project's public JWKS endpoint. Mirrors app/auth.py of modul-x (ES256/RS256, audience "authenticated"). A static X-API-Key (settings.api_key) is accepted for service-to-service calls; both are optional while enforcement is off. """ import logging from typing import Optional import httpx from fastapi import Request from fastapi.responses import JSONResponse from jose import jwt as jose_jwt from jose import jwk as jose_jwk from jose.exceptions import JWTError from .config import settings logger = logging.getLogger("abm.auth") _jwks_cache: Optional[dict] = None def enforcement_on() -> bool: return (settings.license_enforcement or "").strip().lower() == "true" async def _get_jwks() -> dict: global _jwks_cache if _jwks_cache is None: url = f"{settings.supabase_url}/auth/v1/.well-known/jwks.json" async with httpx.AsyncClient(timeout=10.0) as client: res = await client.get(url) res.raise_for_status() _jwks_cache = res.json() return _jwks_cache # type: ignore[return-value] async def verify_supabase_jwt(token: str) -> dict: jwks = await _get_jwks() last_error: Optional[Exception] = None for key_data in jwks.get("keys", []): try: key = jose_jwk.construct(key_data) claims = jose_jwt.decode( token, key, algorithms=["ES256", "RS256"], audience="authenticated", ) if not claims.get("sub"): raise JWTError("JWT has no sub claim") return claims except JWTError as exc: last_error = exc continue raise JWTError(f"JWT verification failed: {last_error}") async def has_abm_license(user_id: str) -> bool: """Active 'abm-engine' license in the central project (service-role read). Falls back to modul-x license so existing customers are not locked out during rollout.""" service_key = settings.supabase_service_role_key if not service_key: return False headers = {"apikey": service_key, "Authorization": f"Bearer {service_key}"} try: async with httpx.AsyncClient(timeout=10.0) as client: res = await client.get( f"{settings.supabase_url}/rest/v1/licenses", params={"user_id": f"eq.{user_id}", "status": "eq.active", "select": "product"}, headers=headers, ) if res.status_code != 200: return False products = {row.get("product") for row in res.json()} return bool(products & {"abm-engine", "modul-x", "combo-full-bridge"}) except Exception: logger.warning("license lookup failed", exc_info=True) return False def _api_key_ok(request: Request) -> bool: supplied = request.headers.get("x-api-key", "") return bool(settings.api_key) and supplied == settings.api_key async def authenticate(request: Request) -> dict: """Authenticate a paid-route request. Accepts, in order: X-API-Key (service calls), Supabase session JWT. Returns {"user_id", "email", "via"}. Raises PermissionError on failure — the route layer maps it to 401/402. When enforcement is off, an invalid JWT still authenticates anonymously so demo deployments keep working. """ if _api_key_ok(request): return {"user_id": "service", "email": None, "via": "api-key"} header = request.headers.get("authorization", "") token = header[7:].strip() if header.lower().startswith("bearer ") else "" if token and settings.supabase_url: try: claims = await verify_supabase_jwt(token) user_id = claims.get("sub", "") if enforcement_on() and not await has_abm_license(user_id): raise PermissionError("402:No active ABM Engine license — see /pricing.") return {"user_id": user_id, "email": claims.get("email"), "via": "jwt"} except PermissionError: raise except JWTError: if enforcement_on(): raise PermissionError("401:Invalid or expired token — sign in again.") return {"user_id": "anonymous", "email": None, "via": "anonymous"} if enforcement_on(): raise PermissionError("401:Missing bearer token — sign in first.") return {"user_id": "anonymous", "email": None, "via": "anonymous"}