File size: 9,852 Bytes
3cdde15 e4ce6eb 3cdde15 1d4d3e9 3cdde15 e4ce6eb 3cdde15 e4ce6eb 3cdde15 | 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 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 | """
AgentIC Auth β Supabase JWT middleware + plan/build-count guard.
Env vars required:
SUPABASE_URL β e.g. https://xyz.supabase.co
SUPABASE_SERVICE_KEY β service-role key (server-side only, never expose)
SUPABASE_JWT_SECRET β JWT secret from Supabase dashboard β Settings β API
ENCRYPTION_KEY β symmetric key for encrypting BYOK API keys (32+ chars)
"""
import hashlib
import hmac
import json
import os
import time
from functools import lru_cache
from typing import Optional, Tuple
import httpx
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
# βββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
SUPABASE_SERVICE_KEY = os.environ.get("SUPABASE_SERVICE_KEY", "")
SUPABASE_JWT_SECRET = os.environ.get("SUPABASE_JWT_SECRET", "")
# ENCRYPTION_KEY must be set in production via env var β never rely on a default.
# If unset, BYOK key storage is disabled with a clear error rather than silently
# using a publicly-known default key that would let anyone decrypt stored keys.
ENCRYPTION_KEY = os.environ.get("ENCRYPTION_KEY", "")
AUTH_ENABLED = bool(SUPABASE_URL and SUPABASE_SERVICE_KEY and SUPABASE_JWT_SECRET)
# Plan limits: max successful builds allowed (None = unlimited)
PLAN_LIMITS = {
"free": 2,
"starter": 25,
"pro": None, # unlimited
"byok": None, # unlimited, uses own key
}
_bearer = HTTPBearer(auto_error=False)
# βββ JWT Decode (no pyjwt dependency β use Supabase /auth/v1/user) ββ
def _decode_supabase_jwt(token: str) -> dict:
"""Validate JWT by calling Supabase auth endpoint.
We call GET /auth/v1/user with the user's access_token.
Supabase verifies the JWT signature and returns the user object.
"""
resp = httpx.get(
f"{SUPABASE_URL}/auth/v1/user",
headers={
"Authorization": f"Bearer {token}",
"apikey": SUPABASE_SERVICE_KEY,
},
timeout=10,
)
if resp.status_code != 200:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return resp.json()
# βββ Supabase DB helpers (use service-role key) βββββββββββββββββββββ
def _supabase_rpc(fn_name: str, params: dict) -> dict:
"""Call a Supabase RPC function."""
resp = httpx.post(
f"{SUPABASE_URL}/rest/v1/rpc/{fn_name}",
headers={
"apikey": SUPABASE_SERVICE_KEY,
"Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
"Content-Type": "application/json",
},
json=params,
timeout=10,
)
resp.raise_for_status()
return resp.json() if resp.text else {}
def _supabase_query(table: str, select: str = "*", filters: str = "") -> list:
"""Simple REST query against Supabase PostgREST."""
url = f"{SUPABASE_URL}/rest/v1/{table}?select={select}"
if filters:
url += f"&{filters}"
resp = httpx.get(
url,
headers={
"apikey": SUPABASE_SERVICE_KEY,
"Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
},
timeout=10,
)
resp.raise_for_status()
return resp.json()
def _supabase_insert(table: str, data: dict) -> dict:
resp = httpx.post(
f"{SUPABASE_URL}/rest/v1/{table}",
headers={
"apikey": SUPABASE_SERVICE_KEY,
"Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
"Content-Type": "application/json",
"Prefer": "return=representation",
},
json=data,
timeout=10,
)
resp.raise_for_status()
rows = resp.json()
return rows[0] if rows else {}
def _supabase_update(table: str, filters: str, data: dict) -> dict:
resp = httpx.patch(
f"{SUPABASE_URL}/rest/v1/{table}?{filters}",
headers={
"apikey": SUPABASE_SERVICE_KEY,
"Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
"Content-Type": "application/json",
"Prefer": "return=representation",
},
json=data,
timeout=10,
)
resp.raise_for_status()
rows = resp.json()
return rows[0] if rows else {}
# βββ BYOK Encryption ββββββββββββββββββββββββββββββββββββββββββββββββ
def encrypt_api_key(plaintext: str) -> str:
"""XOR-based encryption with HMAC integrity check."""
if not ENCRYPTION_KEY:
raise RuntimeError(
"ENCRYPTION_KEY env var is not set. "
"Set a secret 32+ character value in HuggingFace Spaces secrets before storing BYOK keys."
)
key_bytes = hashlib.sha256(ENCRYPTION_KEY.encode()).digest()
ct = bytes(a ^ b for a, b in zip(plaintext.encode(), (key_bytes * ((len(plaintext) // 32) + 1))))
mac = hmac.new(key_bytes, ct, hashlib.sha256).hexdigest()
import base64
return base64.urlsafe_b64encode(ct).decode() + "." + mac
def decrypt_api_key(ciphertext: str) -> str:
import base64
if not ENCRYPTION_KEY:
raise RuntimeError("ENCRYPTION_KEY env var is not set β cannot decrypt stored API key.")
parts = ciphertext.split(".", 1)
if len(parts) != 2:
raise ValueError("Malformed encrypted key")
ct = base64.urlsafe_b64decode(parts[0])
mac = parts[1]
key_bytes = hashlib.sha256(ENCRYPTION_KEY.encode()).digest()
expected_mac = hmac.new(key_bytes, ct, hashlib.sha256).hexdigest()
if not hmac.compare_digest(mac, expected_mac):
raise ValueError("Integrity check failed β key may have been tampered with")
pt = bytes(a ^ b for a, b in zip(ct, (key_bytes * ((len(ct) // 32) + 1))))
return pt.decode()
# βββ FastAPI Dependency: get current user ββββββββββββββββββββββββββββ
async def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer),
) -> Optional[dict]:
"""Extract and validate the Supabase JWT from the Authorization header.
Returns the user profile dict or None if auth is disabled.
When auth is enabled but no valid token is provided, raises 401.
"""
if not AUTH_ENABLED:
return None # Auth not configured β allow anonymous access
if not credentials:
raise HTTPException(status_code=401, detail="Missing Authorization header")
token = credentials.credentials
user = _decode_supabase_jwt(token)
uid = user.get("id")
if not uid:
raise HTTPException(status_code=401, detail="Invalid user")
# Fetch profile from DB
profiles = _supabase_query("profiles", filters=f"id=eq.{uid}")
if not profiles:
raise HTTPException(status_code=404, detail="Profile not found. Sign up first.")
return profiles[0]
# βββ Build Guard: check plan + build count βββββββββββββββββββββββββββ
def check_build_allowed(profile: Optional[dict]) -> None:
"""Raise 402 if the user has exhausted their plan's build quota.
Called before every /build request when auth is enabled.
"""
if profile is None:
return # Auth disabled β no restrictions
plan = profile.get("plan", "free")
builds = profile.get("successful_builds", 0)
limit = PLAN_LIMITS.get(plan)
if limit is not None and builds >= limit:
raise HTTPException(
status_code=402,
detail={
"error": "build_limit_reached",
"plan": plan,
"used": builds,
"limit": limit,
"message": f"You've used all {limit} builds on the {plan} plan. Upgrade to continue building chips.",
"upgrade_url": "/pricing",
},
)
def get_llm_key_for_user(profile: Optional[dict]) -> Optional[str]:
"""Return the user's own LLM API key if they're on the BYOK plan.
Returns None for all other plans (server uses global NVIDIA_API_KEY).
"""
if profile is None:
return None
if profile.get("plan") != "byok":
return None
encrypted_key = profile.get("llm_api_key")
if not encrypted_key:
raise HTTPException(
status_code=400,
detail="BYOK plan requires an API key. Set it in your profile settings.",
)
try:
return decrypt_api_key(encrypted_key)
except ValueError:
raise HTTPException(status_code=500, detail="Failed to decrypt stored API key")
def record_build_start(profile: Optional[dict], job_id: str, design_name: str) -> None:
"""Insert a build record into the builds table."""
if profile is None or not AUTH_ENABLED:
return
_supabase_insert("builds", {
"user_id": profile["id"],
"job_id": job_id,
"design_name": design_name,
"status": "queued",
})
def record_build_success(profile: Optional[dict], job_id: str) -> None:
"""Mark build as done and increment the user's successful_builds count."""
if profile is None or not AUTH_ENABLED:
return
uid = profile["id"]
# Update build row
_supabase_update("builds", f"job_id=eq.{job_id}", {
"status": "done",
"finished_at": "now()",
})
# Increment counter
_supabase_rpc("increment_successful_builds", {"uid": uid})
def record_build_failure(job_id: str) -> None:
"""Mark build as failed."""
if not AUTH_ENABLED:
return
_supabase_update("builds", f"job_id=eq.{job_id}", {
"status": "failed",
"finished_at": "now()",
})
|