Spaces:
Running
Running
refactor: implement stateless Supabase JWT validation to resolve 403 race conditions
Browse files- auth/routes.py +72 -13
- config.py +8 -0
- database.py +16 -1
- dependencies.py +50 -2
- main.py +6 -0
auth/routes.py
CHANGED
|
@@ -312,13 +312,20 @@ async def exchange_session(request: Request, response: Response):
|
|
| 312 |
Token exchange endpoint.
|
| 313 |
|
| 314 |
Accepts a Supabase JWT in the Authorization header, validates it once
|
| 315 |
-
|
| 316 |
β’ A short-lived (15 min) JWT in the response body
|
| 317 |
β’ A long-lived (30 day) opaque refresh token as an HttpOnly cookie
|
| 318 |
|
| 319 |
The frontend should call this right after any Supabase sign-in event
|
| 320 |
(onAuthStateChange fires with a session).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
"""
|
|
|
|
|
|
|
| 322 |
# 1. Extract the Supabase token from the request
|
| 323 |
headers = {k.lower(): v for k, v in request.headers.items()}
|
| 324 |
auth = headers.get("authorization", "")
|
|
@@ -328,23 +335,75 @@ async def exchange_session(request: Request, response: Response):
|
|
| 328 |
if not raw_supabase_token:
|
| 329 |
raise HTTPException(401, "Authorization header with Supabase token required")
|
| 330 |
|
| 331 |
-
#
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
raise HTTPException(503, "Auth service unavailable")
|
| 335 |
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
|
| 343 |
# 3. Ensure a profile row exists (first login race-safe)
|
| 344 |
profile = get_user_by_id(user_id)
|
| 345 |
if not profile:
|
| 346 |
-
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
profile = create_user(name=name, email=email, password="", user_id=user_id)
|
| 349 |
|
| 350 |
# 4. Issue our tokens
|
|
|
|
| 312 |
Token exchange endpoint.
|
| 313 |
|
| 314 |
Accepts a Supabase JWT in the Authorization header, validates it once
|
| 315 |
+
(locally via the Supabase JWT secret β stateless, no network call), then issues:
|
| 316 |
β’ A short-lived (15 min) JWT in the response body
|
| 317 |
β’ A long-lived (30 day) opaque refresh token as an HttpOnly cookie
|
| 318 |
|
| 319 |
The frontend should call this right after any Supabase sign-in event
|
| 320 |
(onAuthStateChange fires with a session).
|
| 321 |
+
|
| 322 |
+
IMPORTANT: We decode the Supabase JWT locally instead of calling
|
| 323 |
+
client.auth.get_user() to avoid a 403 race condition β the Supabase JS
|
| 324 |
+
SDK rotates the session internally immediately after sign-in, so a
|
| 325 |
+
stateful get_user() call often fails before our backend can validate it.
|
| 326 |
"""
|
| 327 |
+
import jwt as pyjwt
|
| 328 |
+
|
| 329 |
# 1. Extract the Supabase token from the request
|
| 330 |
headers = {k.lower(): v for k, v in request.headers.items()}
|
| 331 |
auth = headers.get("authorization", "")
|
|
|
|
| 335 |
if not raw_supabase_token:
|
| 336 |
raise HTTPException(401, "Authorization header with Supabase token required")
|
| 337 |
|
| 338 |
+
# Skip HF space tokens β they're not user auth tokens
|
| 339 |
+
if raw_supabase_token.startswith("hf_"):
|
| 340 |
+
raise HTTPException(401, "HuggingFace space token is not a valid user auth token")
|
|
|
|
| 341 |
|
| 342 |
+
# 2. Decode the Supabase JWT locally (stateless β no round-trip to Supabase)
|
| 343 |
+
supabase_jwt_secret = settings.supabase_jwt_secret
|
| 344 |
+
user_id = None
|
| 345 |
+
email = ""
|
| 346 |
+
user_metadata: dict = {}
|
| 347 |
+
|
| 348 |
+
if supabase_jwt_secret:
|
| 349 |
+
try:
|
| 350 |
+
payload = pyjwt.decode(
|
| 351 |
+
raw_supabase_token,
|
| 352 |
+
supabase_jwt_secret,
|
| 353 |
+
algorithms=["HS256"],
|
| 354 |
+
options={"verify_aud": False}, # Supabase uses 'authenticated' as aud
|
| 355 |
+
)
|
| 356 |
+
user_id = payload.get("sub")
|
| 357 |
+
email = payload.get("email", "")
|
| 358 |
+
user_metadata = payload.get("user_metadata", {})
|
| 359 |
+
except pyjwt.ExpiredSignatureError:
|
| 360 |
+
raise HTTPException(401, "Supabase token has expired. Please sign in again.")
|
| 361 |
+
except pyjwt.InvalidTokenError as e:
|
| 362 |
+
raise HTTPException(401, f"Invalid Supabase token: {e}")
|
| 363 |
+
else:
|
| 364 |
+
# Fallback: decode claims without signature verification if secret is not set
|
| 365 |
+
try:
|
| 366 |
+
import time
|
| 367 |
+
payload = pyjwt.decode(
|
| 368 |
+
raw_supabase_token,
|
| 369 |
+
options={"verify_signature": False, "verify_aud": False},
|
| 370 |
+
)
|
| 371 |
+
exp = payload.get("exp")
|
| 372 |
+
if exp and time.time() > exp:
|
| 373 |
+
raise HTTPException(401, "Supabase token has expired. Please sign in again.")
|
| 374 |
+
user_id = payload.get("sub")
|
| 375 |
+
email = payload.get("email", "")
|
| 376 |
+
user_metadata = payload.get("user_metadata", {})
|
| 377 |
+
except HTTPException:
|
| 378 |
+
raise
|
| 379 |
+
except Exception:
|
| 380 |
+
pass
|
| 381 |
+
|
| 382 |
+
# Fallback: validate via Supabase API (slower, but works if unverified decoding failed)
|
| 383 |
+
if not user_id:
|
| 384 |
+
client = get_auth_supabase() or get_supabase()
|
| 385 |
+
if not client:
|
| 386 |
+
raise HTTPException(503, "Auth service unavailable")
|
| 387 |
+
try:
|
| 388 |
+
sb_user = _verify_token_cached(client, raw_supabase_token)
|
| 389 |
+
user_id = str(sb_user.id)
|
| 390 |
+
email = getattr(sb_user, "email", "") or ""
|
| 391 |
+
user_metadata = getattr(sb_user, "user_metadata", {}) or {}
|
| 392 |
+
except Exception:
|
| 393 |
+
raise HTTPException(401, "Invalid or expired Supabase token")
|
| 394 |
+
|
| 395 |
+
if not user_id:
|
| 396 |
+
raise HTTPException(401, "Could not extract user identity from token")
|
| 397 |
|
| 398 |
# 3. Ensure a profile row exists (first login race-safe)
|
| 399 |
profile = get_user_by_id(user_id)
|
| 400 |
if not profile:
|
| 401 |
+
name = (
|
| 402 |
+
user_metadata.get("full_name")
|
| 403 |
+
or user_metadata.get("name")
|
| 404 |
+
or email.split("@")[0]
|
| 405 |
+
or "User"
|
| 406 |
+
)
|
| 407 |
profile = create_user(name=name, email=email, password="", user_id=user_id)
|
| 408 |
|
| 409 |
# 4. Issue our tokens
|
config.py
CHANGED
|
@@ -25,6 +25,10 @@ class Settings:
|
|
| 25 |
os.getenv("SUPABASE_ANON_KEY")
|
| 26 |
or os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
| 27 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
cloudinary_cloud_name: str = (
|
| 29 |
os.getenv("CLOUDINARY_CLOUD_NAME")
|
| 30 |
or os.getenv("CLOUD_NAME", "")
|
|
@@ -60,6 +64,10 @@ class Settings:
|
|
| 60 |
jwt_algorithm: str = os.getenv("JWT_ALGORITHM", "HS256")
|
| 61 |
# Set to 'development' locally so Secure cookie flag is not required over HTTP
|
| 62 |
environment: str = os.getenv("ENVIRONMENT", "production")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
@lru_cache()
|
|
|
|
| 25 |
os.getenv("SUPABASE_ANON_KEY")
|
| 26 |
or os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
| 27 |
)
|
| 28 |
+
supabase_jwt_secret: str = (
|
| 29 |
+
os.getenv("SUPABASE_JWT_SECRET")
|
| 30 |
+
or os.getenv("JWT_SECRET", "")
|
| 31 |
+
)
|
| 32 |
cloudinary_cloud_name: str = (
|
| 33 |
os.getenv("CLOUDINARY_CLOUD_NAME")
|
| 34 |
or os.getenv("CLOUD_NAME", "")
|
|
|
|
| 64 |
jwt_algorithm: str = os.getenv("JWT_ALGORITHM", "HS256")
|
| 65 |
# Set to 'development' locally so Secure cookie flag is not required over HTTP
|
| 66 |
environment: str = os.getenv("ENVIRONMENT", "production")
|
| 67 |
+
# Supabase JWT secret β used to verify Supabase-issued tokens locally (stateless).
|
| 68 |
+
# Found in: Supabase Dashboard β Project Settings β API β JWT Settings β JWT Secret
|
| 69 |
+
# This avoids the 403 "Session does not exist" error from stateful get_user() calls.
|
| 70 |
+
supabase_jwt_secret: str = os.getenv("SUPABASE_JWT_SECRET", "")
|
| 71 |
|
| 72 |
|
| 73 |
@lru_cache()
|
database.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
|
|
| 1 |
from typing import Optional
|
| 2 |
from supabase import Client, create_client
|
| 3 |
from src.config import settings
|
| 4 |
|
|
|
|
|
|
|
| 5 |
# Singletons β created once, reused on every request
|
| 6 |
_supabase_client: Optional[Client] = None
|
| 7 |
_auth_supabase_client: Optional[Client] = None
|
|
@@ -22,4 +25,16 @@ def get_auth_supabase() -> Optional[Client]:
|
|
| 22 |
if not settings.supabase_url or not settings.supabase_anon_key:
|
| 23 |
return None
|
| 24 |
_auth_supabase_client = create_client(settings.supabase_url, settings.supabase_anon_key)
|
| 25 |
-
return _auth_supabase_client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
from typing import Optional
|
| 3 |
from supabase import Client, create_client
|
| 4 |
from src.config import settings
|
| 5 |
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
# Singletons β created once, reused on every request
|
| 9 |
_supabase_client: Optional[Client] = None
|
| 10 |
_auth_supabase_client: Optional[Client] = None
|
|
|
|
| 25 |
if not settings.supabase_url or not settings.supabase_anon_key:
|
| 26 |
return None
|
| 27 |
_auth_supabase_client = create_client(settings.supabase_url, settings.supabase_anon_key)
|
| 28 |
+
return _auth_supabase_client
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def warmup_database() -> None:
|
| 32 |
+
"""Eagerly instantiate Supabase clients and pre-warm TLS connections during server boot."""
|
| 33 |
+
db_client = get_supabase()
|
| 34 |
+
get_auth_supabase()
|
| 35 |
+
if db_client:
|
| 36 |
+
try:
|
| 37 |
+
db_client.table("profiles").select("id").limit(1).execute()
|
| 38 |
+
logger.info("Supabase database connection warmed up successfully.")
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.warning(f"Database warmup query failed (safe to ignore if offline/testing): {e}")
|
dependencies.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
import time
|
|
|
|
| 2 |
from fastapi import HTTPException, status, Request
|
| 3 |
from fastapi.security import OAuth2PasswordBearer
|
| 4 |
from typing import Any, Optional
|
| 5 |
|
|
|
|
| 6 |
from src.database import get_supabase, get_auth_supabase
|
| 7 |
|
| 8 |
DEV_USER_ID = "00000000-0000-0000-0000-000000000001"
|
|
@@ -75,7 +77,7 @@ async def get_current_user_id(request: Request) -> str:
|
|
| 75 |
if not token:
|
| 76 |
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated")
|
| 77 |
|
| 78 |
-
# ββ Mode 1: our own stateless JWT
|
| 79 |
try:
|
| 80 |
from src.auth.jwt_utils import decode_access_token
|
| 81 |
payload = decode_access_token(token)
|
|
@@ -83,7 +85,30 @@ async def get_current_user_id(request: Request) -> str:
|
|
| 83 |
if user_id:
|
| 84 |
return str(user_id)
|
| 85 |
except Exception:
|
| 86 |
-
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
# ββ Mode 2: Supabase token (backwards-compat for Google OAuth sessions) ββ
|
| 89 |
try:
|
|
@@ -116,6 +141,29 @@ async def get_current_user(request: Request) -> Any:
|
|
| 116 |
except Exception:
|
| 117 |
pass
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
# ββ Mode 2: Supabase token (backwards compat) ββββββββββββββββββββββββββββ
|
| 120 |
try:
|
| 121 |
user = _verify_token_cached(client, token)
|
|
|
|
| 1 |
import time
|
| 2 |
+
import jwt as pyjwt
|
| 3 |
from fastapi import HTTPException, status, Request
|
| 4 |
from fastapi.security import OAuth2PasswordBearer
|
| 5 |
from typing import Any, Optional
|
| 6 |
|
| 7 |
+
from src.config import settings
|
| 8 |
from src.database import get_supabase, get_auth_supabase
|
| 9 |
|
| 10 |
DEV_USER_ID = "00000000-0000-0000-0000-000000000001"
|
|
|
|
| 77 |
if not token:
|
| 78 |
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated")
|
| 79 |
|
| 80 |
+
# ββ Mode 1: our own stateless JWT ββββββββββββββββββββββββββββββββββββββββ
|
| 81 |
try:
|
| 82 |
from src.auth.jwt_utils import decode_access_token
|
| 83 |
payload = decode_access_token(token)
|
|
|
|
| 85 |
if user_id:
|
| 86 |
return str(user_id)
|
| 87 |
except Exception:
|
| 88 |
+
pass
|
| 89 |
+
|
| 90 |
+
# ββ Mode 1b: Supabase JWT stateless fallback (avoids 403 network race) βββ
|
| 91 |
+
try:
|
| 92 |
+
if settings.supabase_jwt_secret:
|
| 93 |
+
payload = pyjwt.decode(
|
| 94 |
+
token,
|
| 95 |
+
settings.supabase_jwt_secret,
|
| 96 |
+
algorithms=["HS256"],
|
| 97 |
+
options={"verify_aud": False},
|
| 98 |
+
)
|
| 99 |
+
else:
|
| 100 |
+
payload = pyjwt.decode(
|
| 101 |
+
token,
|
| 102 |
+
options={"verify_signature": False, "verify_aud": False},
|
| 103 |
+
)
|
| 104 |
+
exp = payload.get("exp")
|
| 105 |
+
if exp and time.time() > exp:
|
| 106 |
+
payload = {}
|
| 107 |
+
user_id = payload.get("sub")
|
| 108 |
+
if user_id:
|
| 109 |
+
return str(user_id)
|
| 110 |
+
except Exception:
|
| 111 |
+
pass
|
| 112 |
|
| 113 |
# ββ Mode 2: Supabase token (backwards-compat for Google OAuth sessions) ββ
|
| 114 |
try:
|
|
|
|
| 141 |
except Exception:
|
| 142 |
pass
|
| 143 |
|
| 144 |
+
# ββ Mode 1b: Supabase JWT stateless fallback βββββββββββββββββββββββββββββ
|
| 145 |
+
try:
|
| 146 |
+
if settings.supabase_jwt_secret:
|
| 147 |
+
payload = pyjwt.decode(
|
| 148 |
+
token,
|
| 149 |
+
settings.supabase_jwt_secret,
|
| 150 |
+
algorithms=["HS256"],
|
| 151 |
+
options={"verify_aud": False},
|
| 152 |
+
)
|
| 153 |
+
else:
|
| 154 |
+
payload = pyjwt.decode(
|
| 155 |
+
token,
|
| 156 |
+
options={"verify_signature": False, "verify_aud": False},
|
| 157 |
+
)
|
| 158 |
+
exp = payload.get("exp")
|
| 159 |
+
if exp and time.time() > exp:
|
| 160 |
+
payload = {}
|
| 161 |
+
user_id = payload.get("sub")
|
| 162 |
+
if user_id:
|
| 163 |
+
return {"id": user_id, "email": payload.get("email", "")}
|
| 164 |
+
except Exception:
|
| 165 |
+
pass
|
| 166 |
+
|
| 167 |
# ββ Mode 2: Supabase token (backwards compat) ββββββββββββββββββββββββββββ
|
| 168 |
try:
|
| 169 |
user = _verify_token_cached(client, token)
|
main.py
CHANGED
|
@@ -56,6 +56,12 @@ logger = logging.getLogger(__name__)
|
|
| 56 |
|
| 57 |
@asynccontextmanager
|
| 58 |
async def lifespan(app: FastAPI):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
try:
|
| 60 |
from src.rag.rag import get_embedder
|
| 61 |
get_embedder()
|
|
|
|
| 56 |
|
| 57 |
@asynccontextmanager
|
| 58 |
async def lifespan(app: FastAPI):
|
| 59 |
+
try:
|
| 60 |
+
from src.database import warmup_database
|
| 61 |
+
warmup_database()
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.warning(f"Database warmup failed: {e}")
|
| 64 |
+
|
| 65 |
try:
|
| 66 |
from src.rag.rag import get_embedder
|
| 67 |
get_embedder()
|