any2human / app /auth /supabase_client.py
idnameraj's picture
Upload 64 files
430efad verified
Raw
History Blame Contribute Delete
5.69 kB
"""Supabase Auth JWT verification + PostgREST helpers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
import jwt
from fastapi import HTTPException, status
from jwt import PyJWKClient
from app.config import (
AUTH_ENABLED,
SUPABASE_ANON_KEY,
SUPABASE_JWT_SECRET,
SUPABASE_SERVICE_ROLE_KEY,
SUPABASE_URL,
)
@dataclass(frozen=True)
class AuthUser:
id: str
email: str | None
_jwks_client: PyJWKClient | None = None
def _get_jwks_client() -> PyJWKClient:
global _jwks_client
if _jwks_client is None:
# Newer Supabase projects sign with asymmetric keys
_jwks_client = PyJWKClient(f"{SUPABASE_URL}/auth/v1/.well-known/jwks.json")
return _jwks_client
def verify_access_token(token: str) -> AuthUser:
"""Validate a Supabase access token and return the user id/email."""
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing access token.")
payload: dict[str, Any] | None = None
errors: list[str] = []
if SUPABASE_JWT_SECRET:
try:
payload = jwt.decode(
token,
SUPABASE_JWT_SECRET,
algorithms=["HS256"],
audience="authenticated",
)
except jwt.PyJWTError as exc:
errors.append(f"hs256:{exc}")
if payload is None:
try:
signing_key = _get_jwks_client().get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["ES256", "RS256"],
audience="authenticated",
)
except Exception as exc: # noqa: BLE001 — fall through to Auth API
errors.append(f"jwks:{exc}")
if payload is None:
# Last resort: ask Supabase Auth
try:
with httpx.Client(timeout=10.0) as client:
res = client.get(
f"{SUPABASE_URL}/auth/v1/user",
headers={
"Authorization": f"Bearer {token}",
"apikey": SUPABASE_ANON_KEY,
},
)
if res.status_code == 200:
data = res.json()
uid = data.get("id")
if uid:
return AuthUser(id=str(uid), email=data.get("email"))
errors.append(f"auth_api:{res.status_code}")
except Exception as exc: # noqa: BLE001
errors.append(f"auth_api:{exc}")
if not payload or not payload.get("sub"):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session. Please sign in again.",
)
return AuthUser(id=str(payload["sub"]), email=payload.get("email"))
def rest_headers(*, service: bool = True) -> dict[str, str]:
key = SUPABASE_SERVICE_ROLE_KEY if service else SUPABASE_ANON_KEY
return {
"apikey": key,
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"Prefer": "return=representation",
}
def rest_get(path: str, params: dict[str, str] | None = None) -> Any:
url = f"{SUPABASE_URL}/rest/v1/{path.lstrip('/')}"
with httpx.Client(timeout=15.0) as client:
res = client.get(url, headers=rest_headers(service=True), params=params or {})
if res.status_code >= 400:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Database error ({res.status_code}).",
)
return res.json()
def rest_patch(path: str, body: dict[str, Any], params: dict[str, str] | None = None) -> Any:
url = f"{SUPABASE_URL}/rest/v1/{path.lstrip('/')}"
with httpx.Client(timeout=15.0) as client:
res = client.patch(
url,
headers=rest_headers(service=True),
params=params or {},
json=body,
)
if res.status_code >= 400:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Database error ({res.status_code}).",
)
return res.json()
def rest_post(path: str, body: dict[str, Any] | list[dict[str, Any]], *, upsert: bool = False) -> Any:
url = f"{SUPABASE_URL}/rest/v1/{path.lstrip('/')}"
headers = rest_headers(service=True)
if upsert:
headers["Prefer"] = "resolution=merge-duplicates,return=representation"
with httpx.Client(timeout=15.0) as client:
res = client.post(url, headers=headers, json=body)
if res.status_code >= 400:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Database error ({res.status_code}).",
)
return res.json()
def auth_public_config() -> dict[str, Any]:
from app.billing.quota import plans_catalog
from app.config import (
GUEST_DAILY_REWRITES,
GUEST_DAILY_WORD_CAP,
GUEST_MAX_WORDS,
SESSION_IDLE_MINUTES,
)
return {
"enabled": AUTH_ENABLED,
"supabase_url": SUPABASE_URL if AUTH_ENABLED else "",
"supabase_anon_key": SUPABASE_ANON_KEY if AUTH_ENABLED else "",
"session_idle_minutes": SESSION_IDLE_MINUTES,
"guest": {
"daily_rewrites": GUEST_DAILY_REWRITES,
"max_words_per_request": GUEST_MAX_WORDS,
"daily_word_cap": GUEST_DAILY_WORD_CAP,
},
"plans": plans_catalog(),
}