File size: 6,501 Bytes
de745bf 5979e23 de745bf | 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 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 | """Supabase JWT verification + license gating for the paid API.
The central Supabase project issues the session JWT; this backend verifies
it against the project's PUBLIC JWKS endpoint (no shared database needed).
License rows are read from the central project via its service-role REST
API (server-side only).
Enforcement is OFF by default (LICENSE_ENFORCEMENT != "true") so existing
open/demo deployments keep working. Turn it on at go-live:
SUPABASE_URL=https://ciytvulujiqemqljablq.supabase.co
SUPABASE_SERVICE_ROLE_KEY=<service role key, server only>
LICENSE_ENFORCEMENT=true
Frontend equivalent lives in the FortyDN website repo (src/lib/productApi.ts).
Follows docs/AUTH_SETUP.md section 3 (Python snippet).
"""
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("emailvalidator.auth")
CENTRAL_SUPABASE_URL = (
settings.supabase_url or "https://ciytvulujiqemqljablq.supabase.co"
)
JWKS_URL = f"{CENTRAL_SUPABASE_URL}/auth/v1/.well-known/jwks.json"
# Modul-X is unlocked directly, via Validate+Send, or via Full Bridge.
MODULX_GRANTS = frozenset(
{
"modul-x",
"combo-validate-send",
"combo-full-bridge",
}
)
_jwks_cache: Optional[dict] = None
def enforcement_on() -> bool:
return (settings.license_enforcement or "").lower() == "true"
async def _get_jwks() -> dict:
"""Fetch (and cache) the Supabase JWKS document."""
global _jwks_cache
if _jwks_cache is None:
async with httpx.AsyncClient(timeout=10.0) as client:
res = await client.get(JWKS_URL)
res.raise_for_status()
_jwks_cache = res.json()
return _jwks_cache # type: ignore[return-value]
def _bearer_token(request: Request) -> Optional[str]:
header = request.headers.get("authorization", "")
if not header.startswith("Bearer "):
return None
token = header[7:].strip()
return token if token else None
async def verify_supabase_jwt(token: str) -> dict:
"""Verify the Supabase session JWT via JWKS. Returns claims (sub = user_id)."""
jwks = await _get_jwks()
# Supabase rotates keys; try each until one verifies.
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_modulx_license(user_id: str) -> bool:
"""Check for an active Modul-X license via the central service-role API."""
service_key = settings.supabase_service_role_key
if not service_key:
logger.warning("[auth] SUPABASE_SERVICE_ROLE_KEY not set - cannot check licenses.")
return False
params = {
"user_id": f"eq.{user_id}",
"status": "eq.active",
"select": "product",
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
res = await client.get(
f"{CENTRAL_SUPABASE_URL}/rest/v1/licenses",
params=params,
headers={
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
},
)
if res.status_code != 200:
# Missing table (migrations not applied yet) => fail closed with a hint.
logger.warning(
"[auth] license lookup HTTP %s: %s",
res.status_code,
res.text[:200],
)
return False
rows = res.json()
return isinstance(rows, list) and any(
isinstance(r, dict) and r.get("product") in MODULX_GRANTS for r in rows
)
except Exception as exc:
logger.warning("[auth] license lookup error: %s", exc)
return False
# Paths that stay open (health probes, docs, root) plus the autonomous-engine
# ops surfaces: /engine/status + /metrics/inference are aggregate diagnostics
# (no PII), and /smtp/probe* enforce their own shared-secret guard
# (X-Worker-Token) — license JWTs do not apply to machine-to-machine probes.
_OPEN_PREFIXES = (
"/api/v1/health",
"/api/health",
"/api/v1/engine/status",
"/api/v1/metrics",
"/api/v1/smtp/probe",
"/docs",
"/redoc",
"/openapi.json",
)
_OPEN_EXACT = frozenset(
{
"/",
"/api/v1/health",
"/api/health",
"/api/v1/engine/status",
"/api/v1/metrics/inference",
"/api/v1/smtp/probe",
}
)
def _is_open_path(path: str) -> bool:
if path in _OPEN_EXACT:
return True
return any(path.startswith(p) and path != p for p in _OPEN_PREFIXES)
async def license_middleware(request: Request, call_next):
"""FastAPI HTTP middleware: JWT + license gate for paid routes.
Mirrors the Express pattern (Modul-S/E): /api/v1/health stays open,
everything else under /api requires a valid JWT + active license.
No-op while LICENSE_ENFORCEMENT != "true" (demo mode).
"""
if not enforcement_on():
return await call_next(request)
path = request.url.path
if _is_open_path(path) or not path.startswith("/api"):
return await call_next(request)
token = _bearer_token(request)
if not token:
return JSONResponse(
status_code=401,
content={"detail": "Missing bearer token - sign in first."},
)
try:
claims = await verify_supabase_jwt(token)
except JWTError:
return JSONResponse(
status_code=401,
content={"detail": "Invalid or expired token."},
)
user_id = claims.get("sub", "")
if not user_id or not await has_modulx_license(user_id):
return JSONResponse(
status_code=402,
content={"detail": "No active Modul-X license - see /pricing."},
)
request.state.auth = {"user_id": user_id, "email": claims.get("email")}
return await call_next(request)
|