Spaces:
Running
Running
File size: 4,636 Bytes
fb0ec21 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 | """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"}
|