UNI12345 commited on
Commit
bfea95e
·
1 Parent(s): 0b15d14

feat: replace Firebase with Supabase JWT auth (ES256/JWKS + HS256 fallback)

Browse files
app/config.py CHANGED
@@ -22,19 +22,11 @@ class Settings(BaseSettings):
22
  NVIDIA_DEEPSEEK_KEY: str
23
  NVIDIA_LLAMA_VISION_KEY: str
24
  NVIDIA_EMBED_KEY: str
25
-
26
- # Firebase Admin SDK
27
- FIREBASE_PROJECT_ID: str
28
- FIREBASE_CLIENT_EMAIL: str
29
- FIREBASE_PRIVATE_KEY: str
30
-
31
- @field_validator("FIREBASE_PRIVATE_KEY")
32
- @classmethod
33
- def clean_private_key(cls, v: str) -> str:
34
- if v:
35
- # Replace escaped newlines if they are passed as text \n
36
- return v.replace("\\n", "\n").replace('"', "")
37
- return v
38
 
39
  # Storage API (Supabase Storage S3-Compatible)
40
  CLOUDFLARE_R2_ACCOUNT_ID: str
 
22
  NVIDIA_DEEPSEEK_KEY: str
23
  NVIDIA_LLAMA_VISION_KEY: str
24
  NVIDIA_EMBED_KEY: str
25
+ # Supabase Auth Settings
26
+ SUPABASE_URL: str = "https://lxqysvqlhajjweorgztt.supabase.co"
27
+ SUPABASE_ANON_KEY: str = ""
28
+ SUPABASE_SERVICE_ROLE_KEY: str = ""
29
+ SUPABASE_JWT_SECRET: str = "super-secret-jwt-token-with-at-least-32-characters-long"
 
 
 
 
 
 
 
 
30
 
31
  # Storage API (Supabase Storage S3-Compatible)
32
  CLOUDFLARE_R2_ACCOUNT_ID: str
app/routers/auth.py CHANGED
@@ -4,7 +4,7 @@ from sqlalchemy.future import select
4
  from datetime import datetime, timedelta
5
  from app.database import get_db
6
  from app import models, schemas
7
- from app.utils.firebase import verify_id_token, create_session_cookie
8
  from app.utils.security import get_current_user
9
  from app.config import settings
10
 
@@ -12,13 +12,13 @@ router = APIRouter(prefix="/auth", tags=["Auth"])
12
 
13
  @router.post("/verify-token", response_model=schemas.UserResponse)
14
  async def verify_token(request: Request, response: Response, body: dict, db: AsyncSession = Depends(get_db)):
15
- id_token = body.get("id_token")
16
  if not id_token:
17
- raise HTTPException(status_code=400, detail="ID token is required")
18
 
19
  try:
20
- # Verify Firebase ID token
21
- decoded_claims = verify_id_token(id_token)
22
  except Exception as e:
23
  raise HTTPException(status_code=401, detail=f"Invalid ID token: {str(e)}")
24
 
@@ -53,18 +53,18 @@ async def verify_token(request: Request, response: Response, body: dict, db: Asy
53
  await db.commit()
54
  await db.refresh(user)
55
 
56
- # Create Firebase Session Cookie (valid for 5 days)
57
- expires_in = timedelta(days=5)
58
- expires_in_seconds = int(expires_in.total_seconds())
59
- try:
60
- session_cookie = create_session_cookie(id_token, expires_in_seconds=expires_in_seconds)
61
- except Exception as e:
62
- raise HTTPException(status_code=401, detail=f"Failed to create session cookie: {str(e)}")
63
 
64
- # Set httpOnly cookie in response
65
  response.set_cookie(
66
  key="archvise_session",
67
- value=session_cookie,
68
  max_age=expires_in_seconds,
69
  expires=expires_in_seconds,
70
  httponly=True,
 
4
  from datetime import datetime, timedelta
5
  from app.database import get_db
6
  from app import models, schemas
7
+ from app.utils.auth import verify_supabase_jwt
8
  from app.utils.security import get_current_user
9
  from app.config import settings
10
 
 
12
 
13
  @router.post("/verify-token", response_model=schemas.UserResponse)
14
  async def verify_token(request: Request, response: Response, body: dict, db: AsyncSession = Depends(get_db)):
15
+ id_token = body.get("id_token") or body.get("access_token")
16
  if not id_token:
17
+ raise HTTPException(status_code=400, detail="ID token or access_token is required")
18
 
19
  try:
20
+ # Verify Supabase JWT token offline
21
+ decoded_claims = verify_supabase_jwt(id_token)
22
  except Exception as e:
23
  raise HTTPException(status_code=401, detail=f"Invalid ID token: {str(e)}")
24
 
 
53
  await db.commit()
54
  await db.refresh(user)
55
 
56
+ # Determine the token's remaining lifetime from 'exp' claim
57
+ token_exp = decoded_claims.get("exp")
58
+ if token_exp:
59
+ now = datetime.utcnow().timestamp()
60
+ expires_in_seconds = max(int(token_exp - now), 0)
61
+ else:
62
+ expires_in_seconds = 5 * 24 * 60 * 60 # Default fallback to 5 days
63
 
64
+ # Set httpOnly cookie in response using the Supabase JWT directly
65
  response.set_cookie(
66
  key="archvise_session",
67
+ value=id_token,
68
  max_age=expires_in_seconds,
69
  expires=expires_in_seconds,
70
  httponly=True,
app/utils/auth.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import jwt
2
+ import httpx
3
+ import time
4
+ from jwt.algorithms import ECAlgorithm
5
+ from app.config import settings
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # JWKS cache — fetched once and refreshed every 12 hours
9
+ # ---------------------------------------------------------------------------
10
+ _jwks_cache: dict = {}
11
+ _jwks_cache_ts: float = 0.0
12
+ _JWKS_TTL = 12 * 60 * 60 # 12 hours in seconds
13
+
14
+ SUPABASE_JWKS_URL = f"{settings.SUPABASE_URL}/auth/v1/.well-known/jwks.json"
15
+
16
+
17
+ def _get_jwks() -> dict:
18
+ """Fetch JWKS from Supabase, using an in-process cache."""
19
+ global _jwks_cache, _jwks_cache_ts
20
+ now = time.time()
21
+ if _jwks_cache and (now - _jwks_cache_ts) < _JWKS_TTL:
22
+ return _jwks_cache
23
+
24
+ try:
25
+ response = httpx.get(SUPABASE_JWKS_URL, timeout=5.0)
26
+ response.raise_for_status()
27
+ data = response.json()
28
+ _jwks_cache = {key["kid"]: key for key in data.get("keys", [])}
29
+ _jwks_cache_ts = now
30
+ return _jwks_cache
31
+ except Exception as e:
32
+ # If refresh fails but we have a stale cache, use it rather than break
33
+ if _jwks_cache:
34
+ return _jwks_cache
35
+ raise RuntimeError(f"Failed to fetch Supabase JWKS: {e}") from e
36
+
37
+
38
+ def _get_public_key(kid: str):
39
+ """Return the PyJWT-compatible public key object for the given key ID."""
40
+ jwks = _get_jwks()
41
+ jwk = jwks.get(kid)
42
+ if not jwk:
43
+ # Refresh cache once and retry (key rotation scenario)
44
+ global _jwks_cache_ts
45
+ _jwks_cache_ts = 0.0
46
+ jwks = _get_jwks()
47
+ jwk = jwks.get(kid)
48
+ if not jwk:
49
+ raise ValueError(f"Unknown JWKS key ID: {kid}")
50
+ return ECAlgorithm.from_jwk(jwk)
51
+
52
+
53
+ def verify_supabase_jwt(token: str) -> dict:
54
+ """
55
+ Decodes and verifies a Supabase-issued JWT.
56
+
57
+ Supports both:
58
+ - ES256 (ECC P-256) — current Supabase signing algorithm via JWKS
59
+ - HS256 — legacy shared-secret tokens (fallback)
60
+
61
+ Returns a normalised claims dict compatible with the rest of the backend.
62
+ """
63
+ # Decode header without verification to determine the algorithm and kid
64
+ try:
65
+ unverified_header = jwt.get_unverified_header(token)
66
+ except jwt.exceptions.DecodeError as e:
67
+ raise ValueError(f"Malformed JWT header: {e}") from e
68
+
69
+ alg = unverified_header.get("alg", "")
70
+ kid = unverified_header.get("kid")
71
+
72
+ if alg == "ES256" and kid:
73
+ # ----------------------------------------------------------------
74
+ # Verify using JWKS (ECC P-256 — current Supabase default)
75
+ # ----------------------------------------------------------------
76
+ public_key = _get_public_key(kid)
77
+ try:
78
+ claims = jwt.decode(
79
+ token,
80
+ public_key,
81
+ algorithms=["ES256"],
82
+ options={"verify_aud": True},
83
+ audience="authenticated",
84
+ )
85
+ except jwt.InvalidAudienceError:
86
+ claims = jwt.decode(
87
+ token,
88
+ public_key,
89
+ algorithms=["ES256"],
90
+ options={"verify_aud": False},
91
+ )
92
+ else:
93
+ # ----------------------------------------------------------------
94
+ # Fallback: HS256 verification with shared JWT secret
95
+ # ----------------------------------------------------------------
96
+ try:
97
+ claims = jwt.decode(
98
+ token,
99
+ settings.SUPABASE_JWT_SECRET,
100
+ algorithms=["HS256"],
101
+ options={"verify_aud": True},
102
+ audience="authenticated",
103
+ )
104
+ except jwt.InvalidAudienceError:
105
+ claims = jwt.decode(
106
+ token,
107
+ settings.SUPABASE_JWT_SECRET,
108
+ algorithms=["HS256"],
109
+ options={"verify_aud": False},
110
+ )
111
+
112
+ # ------------------------------------------------------------------
113
+ # Normalise claims to a consistent dict expected by the rest of the app
114
+ # ------------------------------------------------------------------
115
+ user_metadata = claims.get("user_metadata", {}) or {}
116
+ uid = claims.get("sub")
117
+ email = claims.get("email")
118
+ name = (
119
+ user_metadata.get("full_name")
120
+ or claims.get("name")
121
+ or user_metadata.get("name")
122
+ )
123
+ picture = (
124
+ user_metadata.get("avatar_url")
125
+ or claims.get("picture")
126
+ or user_metadata.get("picture")
127
+ )
128
+
129
+ return {
130
+ "uid": uid,
131
+ "sub": uid,
132
+ "email": email,
133
+ "user_metadata": user_metadata,
134
+ "full_name": name,
135
+ "name": name,
136
+ "avatar_url": picture,
137
+ "picture": picture,
138
+ "exp": claims.get("exp"),
139
+ }
app/utils/security.py CHANGED
@@ -4,7 +4,7 @@ from sqlalchemy.future import select
4
  from datetime import datetime, timedelta
5
  from app.database import get_db
6
  from app import models
7
- from app.utils.firebase import verify_session_cookie
8
 
9
  async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> models.User:
10
  session_cookie = request.cookies.get("archvise_session")
@@ -29,8 +29,8 @@ async def get_current_user(request: Request, db: AsyncSession = Depends(get_db))
29
  "picture": None
30
  }
31
  else:
32
- # Verify Firebase Session Cookie
33
- decoded_claims = verify_session_cookie(session_cookie, check_revoked=True)
34
  except Exception as e:
35
  raise HTTPException(
36
  status_code=status.HTTP_401_UNAUTHORIZED,
 
4
  from datetime import datetime, timedelta
5
  from app.database import get_db
6
  from app import models
7
+ from app.utils.auth import verify_supabase_jwt
8
 
9
  async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> models.User:
10
  session_cookie = request.cookies.get("archvise_session")
 
29
  "picture": None
30
  }
31
  else:
32
+ # Verify Supabase JWT Offline
33
+ decoded_claims = verify_supabase_jwt(session_cookie)
34
  except Exception as e:
35
  raise HTTPException(
36
  status_code=status.HTTP_401_UNAUTHORIZED,
requirements.txt CHANGED
@@ -5,7 +5,6 @@ asyncpg==0.29.0
5
  alembic==1.13.1
6
  redis==5.0.4
7
  rq==1.16.1
8
- firebase-admin==6.5.0
9
  stripe==9.9.0
10
  boto3==1.34.122
11
  PyGithub==2.3.0
 
5
  alembic==1.13.1
6
  redis==5.0.4
7
  rq==1.16.1
 
8
  stripe==9.9.0
9
  boto3==1.34.122
10
  PyGithub==2.3.0