abm-engine / app /auth.py
Z User
feat: ABM Engine v1 β€” OpenSearch store + Apify enrichment + ICP scoring + k-NN memory + optional Neo4j mirror
fb0ec21
Raw History Blame Contribute Delete
4.64 kB
"""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"}