Skip to content

Commit ee0a7cc

Browse files
authored
Merge pull request #253 from DevanshuNEU/fix/jwt-local-decode
perf: switch JWT verification from network call to local decode (OPE-75)
2 parents 932c607 + 3360290 commit ee0a7cc

2 files changed

Lines changed: 208 additions & 18 deletions

File tree

backend/services/auth.py

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from datetime import datetime
1010
from supabase import create_client, Client
1111

12+
from services.observability import logger
13+
1214

1315
class SupabaseAuthService:
1416
"""Supabase authentication and user management"""
@@ -21,46 +23,92 @@ def __init__(self):
2123
if not all([self.supabase_url, self.supabase_key]):
2224
raise ValueError("Supabase credentials not configured")
2325

26+
if not self.jwt_secret:
27+
logger.warning("SUPABASE_JWT_SECRET not set -- falling back to API-based verification")
28+
2429
self.client: Client = create_client(self.supabase_url, self.supabase_key)
2530

2631
def verify_jwt(self, token: str) -> Dict[str, Any]:
2732
"""
28-
Verify Supabase JWT token and return user data
33+
Verify Supabase JWT token locally using the signing secret.
2934
30-
Args:
31-
token: JWT token from Authorization header (format: "Bearer <token>")
32-
33-
Returns:
34-
Dict with user_id, email, and other user metadata
35-
36-
Raises:
37-
HTTPException: If token is invalid or expired
35+
No network call required -- instant verification using HS256.
36+
Falls back to Supabase API call if JWT_SECRET is not configured.
3837
"""
38+
if token.lower().startswith("bearer "):
39+
token = token[7:]
40+
41+
# local decode when secret is available (fast path, no network)
42+
if self.jwt_secret:
43+
return self._verify_local(token)
44+
45+
# fallback: API call to Supabase (slow path, requires network)
46+
return self._verify_via_api(token)
47+
48+
def _verify_local(self, token: str) -> Dict[str, Any]:
49+
"""Decode and verify JWT locally with HS256 secret."""
3950
try:
40-
# Remove "Bearer " prefix if present
41-
if token.startswith("Bearer "):
42-
token = token[7:]
51+
payload = jwt.decode(
52+
token,
53+
self.jwt_secret,
54+
algorithms=["HS256"],
55+
audience="authenticated",
56+
leeway=30,
57+
)
4358

44-
# Use Supabase client to verify token and get user
59+
user_id = payload.get("sub")
60+
if not user_id:
61+
raise HTTPException(
62+
status_code=status.HTTP_401_UNAUTHORIZED,
63+
detail="Token missing subject claim",
64+
)
65+
66+
return {
67+
"user_id": user_id,
68+
"email": payload.get("email"),
69+
"metadata": payload.get("user_metadata") or {},
70+
}
71+
72+
except jwt.ExpiredSignatureError:
73+
raise HTTPException(
74+
status_code=status.HTTP_401_UNAUTHORIZED,
75+
detail="Token expired",
76+
)
77+
except jwt.InvalidAudienceError:
78+
raise HTTPException(
79+
status_code=status.HTTP_401_UNAUTHORIZED,
80+
detail="Invalid token audience",
81+
)
82+
except jwt.InvalidTokenError as e:
83+
logger.debug("JWT decode failed", error=str(e))
84+
raise HTTPException(
85+
status_code=status.HTTP_401_UNAUTHORIZED,
86+
detail="Invalid token",
87+
)
88+
89+
def _verify_via_api(self, token: str) -> Dict[str, Any]:
90+
"""Fallback: verify via Supabase API call (requires network)."""
91+
try:
4592
response = self.client.auth.get_user(token)
4693

4794
if not response.user:
4895
raise HTTPException(
4996
status_code=status.HTTP_401_UNAUTHORIZED,
50-
detail="Invalid or expired token"
97+
detail="Invalid or expired token",
5198
)
5299

53100
return {
54101
"user_id": response.user.id,
55102
"email": response.user.email,
56-
"created_at": response.user.created_at,
57-
"metadata": response.user.user_metadata
103+
"metadata": response.user.user_metadata or {},
58104
}
59-
105+
except HTTPException:
106+
raise
60107
except Exception as e:
108+
logger.debug("API-based JWT verification failed", error=str(e))
61109
raise HTTPException(
62110
status_code=status.HTTP_401_UNAUTHORIZED,
63-
detail=f"Token verification failed: {str(e)}"
111+
detail="Token verification failed",
64112
)
65113

66114
async def signup(self, email: str, password: str, github_username: Optional[str] = None) -> Dict[str, Any]:
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Tests for JWT local decode (OPE-75)."""
2+
import pytest
3+
import jwt as pyjwt
4+
import time
5+
from unittest.mock import patch, MagicMock
6+
7+
8+
JWT_SECRET = "test-jwt-secret-for-unit-tests"
9+
10+
11+
def _make_token(payload: dict, secret: str = JWT_SECRET) -> str:
12+
"""Create a signed JWT for testing."""
13+
defaults = {
14+
"aud": "authenticated",
15+
"exp": int(time.time()) + 3600,
16+
"iat": int(time.time()),
17+
"role": "authenticated",
18+
}
19+
return pyjwt.encode({**defaults, **payload}, secret, algorithm="HS256")
20+
21+
22+
@pytest.fixture
23+
def auth_service():
24+
"""Create auth service with local JWT secret configured."""
25+
with patch("services.auth.create_client") as mock_client:
26+
mock_client.return_value = MagicMock()
27+
with patch.dict("os.environ", {
28+
"SUPABASE_URL": "https://test.supabase.co",
29+
"SUPABASE_ANON_KEY": "test-key",
30+
"SUPABASE_JWT_SECRET": JWT_SECRET,
31+
}):
32+
from services.auth import SupabaseAuthService
33+
yield SupabaseAuthService()
34+
35+
36+
class TestLocalJWTDecode:
37+
"""Verify local JWT decode works without network calls."""
38+
39+
def test_valid_token_returns_user_data(self, auth_service):
40+
token = _make_token({"sub": "user-123", "email": "dev@test.com", "user_metadata": {"tier": "pro"}})
41+
result = auth_service.verify_jwt(token)
42+
43+
assert result["user_id"] == "user-123"
44+
assert result["email"] == "dev@test.com"
45+
assert result["metadata"]["tier"] == "pro"
46+
47+
def test_bearer_prefix_stripped(self, auth_service):
48+
token = _make_token({"sub": "user-456", "email": "a@b.com"})
49+
result = auth_service.verify_jwt(f"Bearer {token}")
50+
51+
assert result["user_id"] == "user-456"
52+
53+
def test_expired_token_raises_401(self, auth_service):
54+
token = _make_token({"sub": "user-789", "exp": int(time.time()) - 60})
55+
56+
from fastapi import HTTPException
57+
with pytest.raises(HTTPException) as exc:
58+
auth_service.verify_jwt(token)
59+
assert exc.value.status_code == 401
60+
assert "expired" in exc.value.detail.lower()
61+
62+
def test_wrong_secret_raises_401(self, auth_service):
63+
token = _make_token({"sub": "user-000"}, secret="wrong-secret")
64+
65+
from fastapi import HTTPException
66+
with pytest.raises(HTTPException) as exc:
67+
auth_service.verify_jwt(token)
68+
assert exc.value.status_code == 401
69+
70+
def test_missing_sub_claim_raises_401(self, auth_service):
71+
token = _make_token({"email": "no-sub@test.com"})
72+
73+
from fastapi import HTTPException
74+
with pytest.raises(HTTPException) as exc:
75+
auth_service.verify_jwt(token)
76+
assert exc.value.status_code == 401
77+
assert "subject" in exc.value.detail.lower()
78+
79+
def test_wrong_audience_raises_401(self, auth_service):
80+
payload = {"sub": "user-aud", "aud": "wrong-audience", "exp": int(time.time()) + 3600}
81+
token = pyjwt.encode(payload, JWT_SECRET, algorithm="HS256")
82+
83+
from fastapi import HTTPException
84+
with pytest.raises(HTTPException) as exc:
85+
auth_service.verify_jwt(token)
86+
assert exc.value.status_code == 401
87+
88+
def test_no_network_call_made(self, auth_service):
89+
"""The whole point of OPE-75: verify_jwt should NOT hit the network."""
90+
token = _make_token({"sub": "user-net", "email": "net@test.com"})
91+
92+
auth_service.verify_jwt(token)
93+
94+
# get_user should never be called when jwt_secret is available
95+
auth_service.client.auth.get_user.assert_not_called()
96+
97+
def test_metadata_defaults_to_empty_dict(self, auth_service):
98+
token = _make_token({"sub": "user-no-meta", "email": "x@y.com"})
99+
result = auth_service.verify_jwt(token)
100+
101+
assert result["metadata"] == {}
102+
103+
def test_metadata_null_coalesced_to_empty_dict(self, auth_service):
104+
"""user_metadata can be explicitly null in Supabase JWTs."""
105+
token = _make_token({"sub": "user-null-meta", "user_metadata": None})
106+
result = auth_service.verify_jwt(token)
107+
108+
assert result["metadata"] == {}
109+
110+
def test_bearer_prefix_case_insensitive(self, auth_service):
111+
token = _make_token({"sub": "user-case"})
112+
for prefix in ["Bearer ", "bearer ", "BEARER "]:
113+
result = auth_service.verify_jwt(f"{prefix}{token}")
114+
assert result["user_id"] == "user-case"
115+
116+
117+
class TestAPIFallback:
118+
"""When JWT secret is not configured, fall back to Supabase API."""
119+
120+
def test_falls_back_to_api_when_no_secret(self):
121+
with patch("services.auth.create_client") as mock_client:
122+
client = MagicMock()
123+
user = MagicMock()
124+
user.id = "api-user-123"
125+
user.email = "api@test.com"
126+
user.user_metadata = {"tier": "free"}
127+
response = MagicMock()
128+
response.user = user
129+
client.auth.get_user.return_value = response
130+
mock_client.return_value = client
131+
132+
with patch.dict("os.environ", {
133+
"SUPABASE_URL": "https://test.supabase.co",
134+
"SUPABASE_ANON_KEY": "test-key",
135+
"SUPABASE_JWT_SECRET": "",
136+
}):
137+
from services.auth import SupabaseAuthService
138+
service = SupabaseAuthService()
139+
result = service.verify_jwt("some-token")
140+
141+
assert result["user_id"] == "api-user-123"
142+
client.auth.get_user.assert_called_once_with("some-token")

0 commit comments

Comments
 (0)