Hamdy005 commited on
Commit
ef8e0a7
Β·
1 Parent(s): 25c8a13

feat: implement stateless JWT-based authentication with refresh tokens stored in HTTPOnly Cookies

Browse files
auth/constants.py CHANGED
@@ -20,3 +20,20 @@ EMAIL_RATE_LIMITS = {
20
  "forgot_password": {"limit": 3, "window_seconds": 3600},
21
  "change_password_confirmation": {"limit": 3, "window_seconds": 3600},
22
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  "forgot_password": {"limit": 3, "window_seconds": 3600},
21
  "change_password_confirmation": {"limit": 3, "window_seconds": 3600},
22
  }
23
+
24
+ # ── JWT / Access token ────────────────────────────────────────────────────────
25
+
26
+ # Short-lived JWT lifetime. Stateless: verified via signature only, no DB lookup.
27
+ ACCESS_TOKEN_EXPIRE_MINUTES = 15
28
+
29
+ # ── Refresh token ─────────────────────────────────────────────────────────────
30
+
31
+ # Long-lived opaque token lifetime. Stateful: looked up in the DB on every use.
32
+ REFRESH_TOKEN_EXPIRE_DAYS = 30
33
+
34
+ # ── HttpOnly cookie settings ──────────────────────────────────────────────────
35
+
36
+ REFRESH_COOKIE_NAME = "refresh_token"
37
+ # Scope the cookie to the auth sub-path so it is NOT sent to /api/materials etc.
38
+ REFRESH_COOKIE_PATH = "/api/auth"
39
+ REFRESH_COOKIE_MAX_AGE = REFRESH_TOKEN_EXPIRE_DAYS * 24 * 3600 # seconds
auth/jwt_utils.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ JWT and refresh-token utilities.
3
+
4
+ Responsibilities:
5
+ - Sign / verify short-lived access tokens (15 min) with our own secret key.
6
+ - Generate opaque refresh token strings and produce their SHA-256 hash for
7
+ safe storage in the database.
8
+
9
+ No FastAPI or database imports β€” pure utility module.
10
+ """
11
+ import hashlib
12
+ import secrets
13
+ from datetime import datetime, timedelta, timezone
14
+
15
+ import jwt
16
+
17
+ from src.config import settings
18
+ from src.auth.constants import (
19
+ ACCESS_TOKEN_EXPIRE_MINUTES,
20
+ REFRESH_TOKEN_EXPIRE_DAYS,
21
+ )
22
+
23
+
24
+ def create_access_token(user_id: str, email: str) -> str:
25
+ """Return a signed JWT that expires in ACCESS_TOKEN_EXPIRE_MINUTES."""
26
+ if not settings.jwt_secret_key:
27
+ raise RuntimeError(
28
+ "JWT_SECRET_KEY is not configured. "
29
+ "Add it to config.env (generate with: openssl rand -hex 32)."
30
+ )
31
+ now = datetime.now(timezone.utc)
32
+ payload = {
33
+ "sub": user_id,
34
+ "email": email,
35
+ "iat": now,
36
+ "exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
37
+ }
38
+ return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
39
+
40
+
41
+ def decode_access_token(token: str) -> dict:
42
+ """
43
+ Decode and verify a JWT issued by create_access_token.
44
+
45
+ Returns the payload dict on success.
46
+ Raises jwt.ExpiredSignatureError if the token has expired.
47
+ Raises jwt.InvalidTokenError (or subclass) for any other problem.
48
+ """
49
+ if not settings.jwt_secret_key:
50
+ raise RuntimeError("JWT_SECRET_KEY is not configured.")
51
+ return jwt.decode(
52
+ token,
53
+ settings.jwt_secret_key,
54
+ algorithms=[settings.jwt_algorithm],
55
+ )
56
+
57
+
58
+ # ── Refresh token ─────────────────────────────────────────────────────────────
59
+
60
+ REFRESH_TOKEN_EXPIRE_DAYS = 30
61
+
62
+
63
+ def create_refresh_token() -> str:
64
+ """Return a cryptographically-secure 64-char hex string (32 random bytes)."""
65
+ return secrets.token_hex(32)
66
+
67
+
68
+ def hash_token(raw: str) -> str:
69
+ """SHA-256 hex digest β€” safe to store in the DB instead of the raw value."""
70
+ return hashlib.sha256(raw.encode()).hexdigest()
auth/refresh_token_store.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stateful refresh-token store.
3
+
4
+ Wraps the `refresh_tokens` Supabase table with simple CRUD helpers.
5
+ All functions are synchronous and use the existing `_table_supabase`
6
+ helper from `store.py`, so they get the same dev-mode fallback behaviour.
7
+
8
+ Table DDL (run once in the Supabase SQL editor):
9
+
10
+ CREATE TABLE IF NOT EXISTS refresh_tokens (
11
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
12
+ user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
13
+ token_hash TEXT NOT NULL UNIQUE,
14
+ expires_at TIMESTAMPTZ NOT NULL,
15
+ revoked BOOLEAN NOT NULL DEFAULT FALSE,
16
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
17
+ );
18
+ CREATE INDEX ON refresh_tokens(token_hash);
19
+ CREATE INDEX ON refresh_tokens(user_id);
20
+ """
21
+
22
+ import logging
23
+ from datetime import datetime, timedelta, timezone
24
+ from typing import Optional
25
+
26
+ from src.store import _table_supabase, _robust_execute
27
+ from src.auth.constants import REFRESH_TOKEN_EXPIRE_DAYS
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def _now_utc() -> datetime:
33
+ return datetime.now(timezone.utc)
34
+
35
+
36
+ def save_refresh_token(user_id: str, token_hash: str) -> None:
37
+ """Persist a new (un-revoked) refresh token row for *user_id*."""
38
+ expires_at = _now_utc() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
39
+ try:
40
+ _robust_execute(
41
+ _table_supabase("refresh_tokens").insert({
42
+ "user_id": user_id,
43
+ "token_hash": token_hash,
44
+ "expires_at": expires_at.isoformat(),
45
+ "revoked": False,
46
+ })
47
+ )
48
+ except Exception as e:
49
+ logger.error("save_refresh_token failed: %s", e)
50
+ raise
51
+
52
+
53
+ def get_refresh_token(token_hash: str) -> Optional[dict]:
54
+ """
55
+ Look up a refresh token row by its hash.
56
+
57
+ Returns the raw DB row dict, or None if not found.
58
+ """
59
+ try:
60
+ result = _robust_execute(
61
+ _table_supabase("refresh_tokens")
62
+ .select("*")
63
+ .eq("token_hash", token_hash)
64
+ )
65
+ rows = result.data
66
+ if isinstance(rows, list):
67
+ return rows[0] if rows else None
68
+ return rows or None
69
+ except Exception as e:
70
+ logger.error("get_refresh_token failed: %s", e)
71
+ return None
72
+
73
+
74
+ def revoke_refresh_token(token_hash: str) -> None:
75
+ """Mark a single token as revoked."""
76
+ try:
77
+ _robust_execute(
78
+ _table_supabase("refresh_tokens")
79
+ .update({"revoked": True})
80
+ .eq("token_hash", token_hash)
81
+ )
82
+ except Exception as e:
83
+ logger.error("revoke_refresh_token failed: %s", e)
84
+
85
+
86
+ def revoke_all_user_tokens(user_id: str) -> None:
87
+ """Revoke every active refresh token for *user_id* (logout-everywhere)."""
88
+ try:
89
+ _robust_execute(
90
+ _table_supabase("refresh_tokens")
91
+ .update({"revoked": True})
92
+ .eq("user_id", user_id)
93
+ )
94
+ except Exception as e:
95
+ logger.error("revoke_all_user_tokens failed for %s: %s", user_id, e)
96
+
97
+
98
+ def is_token_valid(row: dict) -> bool:
99
+ """
100
+ Return True if the DB row represents a currently-valid refresh token.
101
+
102
+ Checks: row exists, not revoked, not expired.
103
+ """
104
+ if not row:
105
+ return False
106
+ if row.get("revoked"):
107
+ return False
108
+ expires_at_raw = row.get("expires_at")
109
+ if not expires_at_raw:
110
+ return False
111
+ # Supabase returns ISO-8601 strings; parse and compare.
112
+ try:
113
+ if isinstance(expires_at_raw, str):
114
+ # Handle both 'Z' suffix and '+00:00' offset
115
+ expires_at_raw = expires_at_raw.replace("Z", "+00:00")
116
+ expires_at = datetime.fromisoformat(expires_at_raw)
117
+ else:
118
+ expires_at = expires_at_raw
119
+ if expires_at.tzinfo is None:
120
+ expires_at = expires_at.replace(tzinfo=timezone.utc)
121
+ return _now_utc() < expires_at
122
+ except Exception:
123
+ return False
auth/routes.py CHANGED
@@ -1,14 +1,26 @@
1
  import uuid
2
  import cloudinary
3
  import cloudinary.uploader
4
- from fastapi import APIRouter, HTTPException, UploadFile, File
 
5
  from fastapi import Depends
6
  from typing import Optional
7
 
8
  from src.config import settings
9
  from src.database import get_auth_supabase, get_supabase
10
  from src.store import create_user, get_user_by_email, delete_user_data, update_user_profile, get_user_by_id
11
- from src.dependencies import get_current_user_id, get_current_user
 
 
 
 
 
 
 
 
 
 
 
12
  from .schemas import ProfileUpdateRequest, EmailRateLimitRequest
13
  from .constants import (
14
  ALLOWED_MIME_TYPES,
@@ -16,6 +28,9 @@ from .constants import (
16
  AVATAR_BUCKET,
17
  PLACEHOLDER_DOMAINS,
18
  EMAIL_RATE_LIMITS,
 
 
 
19
  )
20
  from .rate_limiter import (
21
  enforce_email_rate_limit,
@@ -26,6 +41,33 @@ from .rate_limiter import (
26
  router = APIRouter(prefix="/api/auth", tags=["Auth"])
27
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  @router.post("/upload-avatar")
30
  async def upload_avatar(
31
  file: UploadFile = File(...),
@@ -262,3 +304,159 @@ async def email_limit_status(action: str, email: str):
262
  return {"status": "success", "action": action, "email": email, **status_info}
263
 
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import uuid
2
  import cloudinary
3
  import cloudinary.uploader
4
+ from datetime import timezone, datetime
5
+ from fastapi import APIRouter, HTTPException, UploadFile, File, Response, Request
6
  from fastapi import Depends
7
  from typing import Optional
8
 
9
  from src.config import settings
10
  from src.database import get_auth_supabase, get_supabase
11
  from src.store import create_user, get_user_by_email, delete_user_data, update_user_profile, get_user_by_id
12
+ from src.dependencies import get_current_user_id, get_current_user, _verify_token_cached
13
+ from src.auth.jwt_utils import (
14
+ create_access_token,
15
+ create_refresh_token,
16
+ hash_token,
17
+ )
18
+ from src.auth.refresh_token_store import (
19
+ save_refresh_token,
20
+ get_refresh_token,
21
+ revoke_refresh_token,
22
+ is_token_valid,
23
+ )
24
  from .schemas import ProfileUpdateRequest, EmailRateLimitRequest
25
  from .constants import (
26
  ALLOWED_MIME_TYPES,
 
28
  AVATAR_BUCKET,
29
  PLACEHOLDER_DOMAINS,
30
  EMAIL_RATE_LIMITS,
31
+ REFRESH_COOKIE_NAME,
32
+ REFRESH_COOKIE_PATH,
33
+ REFRESH_COOKIE_MAX_AGE,
34
  )
35
  from .rate_limiter import (
36
  enforce_email_rate_limit,
 
41
  router = APIRouter(prefix="/api/auth", tags=["Auth"])
42
 
43
 
44
+ # ── Cookie helper ─────────────────────────────────────────────────────────────
45
+ # Constants are defined in src/auth/constants.py
46
+
47
+
48
+ def _set_refresh_cookie(response: Response, raw_token: str) -> None:
49
+ """Attach the refresh token as an HttpOnly cookie on *response*."""
50
+ response.set_cookie(
51
+ key=REFRESH_COOKIE_NAME,
52
+ value=raw_token,
53
+ httponly=True,
54
+ secure=(settings.environment != "development"),
55
+ samesite="none", # required for cross-origin Vercel ↔ HF Space
56
+ max_age=REFRESH_COOKIE_MAX_AGE,
57
+ path=REFRESH_COOKIE_PATH,
58
+ )
59
+
60
+
61
+ def _clear_refresh_cookie(response: Response) -> None:
62
+ """Remove the refresh token cookie."""
63
+ response.delete_cookie(
64
+ key=REFRESH_COOKIE_NAME,
65
+ path=REFRESH_COOKIE_PATH,
66
+ samesite="none",
67
+ secure=(settings.environment != "development"),
68
+ )
69
+
70
+
71
  @router.post("/upload-avatar")
72
  async def upload_avatar(
73
  file: UploadFile = File(...),
 
304
  return {"status": "success", "action": action, "email": email, **status_info}
305
 
306
 
307
+ # ── Token Exchange & Refresh ───────────────────────────────────────────────────
308
+
309
+ @router.post("/session")
310
+ async def exchange_session(request: Request, response: Response):
311
+ """
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", "")
325
+ x_auth = headers.get("x-auth-token", "")
326
+ raw_supabase_token = x_auth or (auth[len("Bearer "):].strip() if auth.startswith("Bearer ") else None)
327
+
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
351
+ access_token = create_access_token(user_id, email)
352
+ raw_refresh = create_refresh_token()
353
+ refresh_hash = hash_token(raw_refresh)
354
+ save_refresh_token(user_id, refresh_hash)
355
+
356
+ _set_refresh_cookie(response, raw_refresh)
357
+
358
+ return {
359
+ "access_token": access_token,
360
+ "token_type": "bearer",
361
+ "user": profile,
362
+ }
363
+
364
+
365
+ @router.post("/refresh")
366
+ async def refresh_session(request: Request, response: Response):
367
+ """
368
+ Silently re-issue a new access token using the HttpOnly refresh token cookie.
369
+
370
+ Rotates the refresh token on every use (old token is revoked, new one is issued)
371
+ so a stolen token can only be used once before it's invalidated.
372
+ """
373
+ raw = request.cookies.get(REFRESH_COOKIE_NAME)
374
+ if not raw:
375
+ raise HTTPException(401, "No refresh token cookie found")
376
+
377
+ token_hash = hash_token(raw)
378
+ row = get_refresh_token(token_hash)
379
+
380
+ if not row or not is_token_valid(row):
381
+ _clear_refresh_cookie(response)
382
+ raise HTTPException(401, "Refresh token is invalid, expired, or revoked")
383
+
384
+ user_id = str(row["user_id"])
385
+
386
+ # Fetch the user's email for the new access token payload
387
+ profile = get_user_by_id(user_id)
388
+ email = (profile or {}).get("email", "")
389
+
390
+ # Rotate: revoke old, issue new refresh token
391
+ revoke_refresh_token(token_hash)
392
+ new_raw_refresh = create_refresh_token()
393
+ new_hash = hash_token(new_raw_refresh)
394
+ save_refresh_token(user_id, new_hash)
395
+
396
+ access_token = create_access_token(user_id, email)
397
+
398
+ _set_refresh_cookie(response, new_raw_refresh)
399
+
400
+ return {
401
+ "access_token": access_token,
402
+ "token_type": "bearer",
403
+ }
404
+
405
+
406
+ @router.post("/logout")
407
+ async def logout(request: Request, response: Response):
408
+ """
409
+ Revoke the refresh token in the database and clear the cookie.
410
+
411
+ This is the only true logout β€” do not rely on JWT expiry alone.
412
+ The short-lived access token will expire naturally within 15 minutes.
413
+ """
414
+ raw = request.cookies.get(REFRESH_COOKIE_NAME)
415
+ if raw:
416
+ token_hash = hash_token(raw)
417
+ revoke_refresh_token(token_hash)
418
+
419
+ _clear_refresh_cookie(response)
420
+ return {"status": "ok", "message": "Logged out successfully"}
421
+
422
+
423
+ @router.get("/me")
424
+ async def get_me(
425
+ user_id: str = Depends(get_current_user_id),
426
+ current_user=Depends(get_current_user),
427
+ ):
428
+ """
429
+ Return the authenticated user's profile.
430
+
431
+ Protected route β€” requires a valid JWT in Authorization: Bearer header.
432
+ This is a thin wrapper over the existing get_profile logic so both
433
+ /api/auth/profile and /api/auth/me return the same shape.
434
+ """
435
+ user = get_user_by_id(user_id)
436
+ if not user:
437
+ # Fallback: build minimal profile from JWT payload
438
+ uid = (
439
+ getattr(current_user, "id", None)
440
+ or (current_user.get("id") if isinstance(current_user, dict) else None)
441
+ )
442
+ if uid:
443
+ from src.store import _map_profile, get_usage
444
+ email = (
445
+ getattr(current_user, "email", "") or
446
+ (current_user.get("email") if isinstance(current_user, dict) else "") or ""
447
+ )
448
+ real_usage = get_usage(uid)
449
+ user = _map_profile({
450
+ "id": uid,
451
+ "display_name": email.split("@")[0] or "User",
452
+ "email": email,
453
+ "avatar_url": "",
454
+ "daily_requests": real_usage.get("used", 0),
455
+ "last_request_date": (
456
+ datetime.now(timezone.utc).date().isoformat()
457
+ ),
458
+ })
459
+
460
+ if not user:
461
+ raise HTTPException(404, "Profile not found")
462
+ return {"status": "success", "user": user}
config.py CHANGED
@@ -55,6 +55,12 @@ class Settings:
55
  if w.strip()
56
  ]
57
 
 
 
 
 
 
 
58
 
59
  @lru_cache()
60
  def get_settings() -> Settings:
 
55
  if w.strip()
56
  ]
57
 
58
+ # ── JWT / Refresh-Token Auth ───────────────────────────────────────────────
59
+ jwt_secret_key: str = os.getenv("JWT_SECRET_KEY", "")
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()
66
  def get_settings() -> Settings:
dependencies.py CHANGED
@@ -1,5 +1,5 @@
1
  import time
2
- from fastapi import Depends, HTTPException, Header, status, Request
3
  from fastapi.security import OAuth2PasswordBearer
4
  from typing import Any, Optional
5
 
@@ -41,8 +41,8 @@ def _verify_token_cached(client, token: str) -> Any:
41
 
42
  def _extract_token(request: Request) -> Optional[str]:
43
  """
44
- Extract Supabase JWT from headers β€” case-insensitive.
45
- Priority: X-Auth-Token β†’ Authorization (skip HF tokens)
46
  """
47
  headers = {k.lower(): v for k, v in request.headers.items()}
48
 
@@ -64,10 +64,9 @@ def _extract_token(request: Request) -> Optional[str]:
64
 
65
 
66
  async def get_current_user_id(request: Request) -> str:
67
- # Use a single cached client β€” prefer the auth client, fall back to service client
68
  client = get_auth_supabase() or get_supabase()
69
 
70
- # Dev mode
71
  if client is None:
72
  return DEV_USER_ID
73
 
@@ -76,6 +75,17 @@ async def get_current_user_id(request: Request) -> str:
76
  if not token:
77
  raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated")
78
 
 
 
 
 
 
 
 
 
 
 
 
79
  try:
80
  user = _verify_token_cached(client, token)
81
  return str(user.id)
@@ -85,7 +95,6 @@ async def get_current_user_id(request: Request) -> str:
85
 
86
 
87
  async def get_current_user(request: Request) -> Any:
88
- # Use a single cached client β€” prefer the auth client, fall back to service client
89
  client = get_auth_supabase() or get_supabase()
90
 
91
  # Dev mode
@@ -94,13 +103,25 @@ async def get_current_user(request: Request) -> Any:
94
 
95
  token = _extract_token(request)
96
 
97
- if token:
98
- try:
99
- user = _verify_token_cached(client, token)
100
- if user:
101
- return user
102
- except Exception as e:
103
- print(f"Token validation error: {e}")
104
- pass
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
  raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated")
 
1
  import time
2
+ from fastapi import HTTPException, status, Request
3
  from fastapi.security import OAuth2PasswordBearer
4
  from typing import Any, Optional
5
 
 
41
 
42
  def _extract_token(request: Request) -> Optional[str]:
43
  """
44
+ Extract JWT from headers β€” case-insensitive.
45
+ Priority: X-Auth-Token β†’ Authorization (skip HF space tokens)
46
  """
47
  headers = {k.lower(): v for k, v in request.headers.items()}
48
 
 
64
 
65
 
66
  async def get_current_user_id(request: Request) -> str:
 
67
  client = get_auth_supabase() or get_supabase()
68
 
69
+ # Dev mode β€” no Supabase configured
70
  if client is None:
71
  return DEV_USER_ID
72
 
 
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)
82
+ user_id = payload.get("sub")
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:
90
  user = _verify_token_cached(client, token)
91
  return str(user.id)
 
95
 
96
 
97
  async def get_current_user(request: Request) -> Any:
 
98
  client = get_auth_supabase() or get_supabase()
99
 
100
  # Dev mode
 
103
 
104
  token = _extract_token(request)
105
 
106
+ if not token:
107
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated")
108
+
109
+ # ── Mode 1: our own stateless JWT ────────────────────────────────────────
110
+ try:
111
+ from src.auth.jwt_utils import decode_access_token
112
+ payload = decode_access_token(token)
113
+ user_id = payload.get("sub")
114
+ if user_id:
115
+ return {"id": user_id, "email": payload.get("email", "")}
116
+ except Exception:
117
+ pass
118
+
119
+ # ── Mode 2: Supabase token (backwards compat) ────────────────────────────
120
+ try:
121
+ user = _verify_token_cached(client, token)
122
+ if user:
123
+ return user
124
+ except Exception as e:
125
+ print(f"Token validation error: {e}")
126
 
127
  raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated")
requirements.txt CHANGED
@@ -29,6 +29,8 @@ python-multipart
29
  pydantic
30
  httpx
31
  cloudinary
 
 
32
 
33
  supabase
34
  transformers
 
29
  pydantic
30
  httpx
31
  cloudinary
32
+ PyJWT>=2.8.0
33
+ passlib[bcrypt]>=1.7.4
34
 
35
  supabase
36
  transformers