"""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(), }