Hamdy005 commited on
Commit
37bcb58
Β·
1 Parent(s): ef8e0a7

refactor: implement stateless Supabase JWT validation to resolve 403 race conditions

Browse files
Files changed (5) hide show
  1. auth/routes.py +72 -13
  2. config.py +8 -0
  3. database.py +16 -1
  4. dependencies.py +50 -2
  5. 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
- against Supabase, 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
  # 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
- # 2. Validate the Supabase token once
332
- client = get_auth_supabase() or get_supabase()
333
- if not client:
334
- raise HTTPException(503, "Auth service unavailable")
335
 
336
- try:
337
- sb_user = _verify_token_cached(client, raw_supabase_token)
338
- user_id = str(sb_user.id)
339
- email = getattr(sb_user, "email", "") or ""
340
- except Exception:
341
- raise HTTPException(401, "Invalid or expired Supabase token")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- meta = getattr(sb_user, "user_metadata", {}) or {}
347
- name = meta.get("full_name") or meta.get("name") or email.split("@")[0] or "User"
 
 
 
 
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 (fast β€” no network call) ───────────────
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 # Fall through to Supabase validation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()