Skip to content

Commit 57122e5

Browse files
committed
perf: switch JWT verification from Supabase API call to local decode
Before: Every authenticated request (23 routes) made a network call to self.client.auth.get_user(token) on Supabase's API. Added latency to every request and created a hard dependency on Supabase availability. After: jwt.decode() with HS256 secret verifies locally in microseconds. Zero network calls for auth. Falls back to API call only if SUPABASE_JWT_SECRET is not configured. Changes: - verify_jwt() now tries local decode first via _verify_local() - Falls back to _verify_via_api() when jwt_secret is empty - Specific error messages for expired, invalid audience, bad signature - No longer leaks internal error details (was: detail=f'...{str(e)}') - Removed unused 'created_at' from return dict (no consumer uses it) - Added logger import for debug-level decode failure logging Tests: 9 new tests in test_jwt_local_decode.py covering: - Valid token decode, Bearer prefix stripping - Expired token, wrong secret, missing sub, wrong audience - Verifies NO network call made (assert_not_called on get_user) - API fallback when jwt_secret is empty - Metadata defaults to empty dict 279 tests pass (270 existing + 9 new). Closes OPE-75
1 parent 932c607 commit 57122e5

2 files changed

Lines changed: 194 additions & 18 deletions

File tree

backend/services/auth.py

Lines changed: 65 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,91 @@ 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.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+
)
4357

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

4793
if not response.user:
4894
raise HTTPException(
4995
status_code=status.HTTP_401_UNAUTHORIZED,
50-
detail="Invalid or expired token"
96+
detail="Invalid or expired token",
5197
)
5298

5399
return {
54100
"user_id": response.user.id,
55101
"email": response.user.email,
56-
"created_at": response.user.created_at,
57-
"metadata": response.user.user_metadata
102+
"metadata": response.user.user_metadata or {},
58103
}
59-
104+
except HTTPException:
105+
raise
60106
except Exception as e:
107+
logger.debug("API-based JWT verification failed", error=str(e))
61108
raise HTTPException(
62109
status_code=status.HTTP_401_UNAUTHORIZED,
63-
detail=f"Token verification failed: {str(e)}"
110+
detail="Token verification failed",
64111
)
65112

66113
async def signup(self, email: str, password: str, github_username: Optional[str] = None) -> Dict[str, Any]:
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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+
return 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()) - 10})
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+
104+
class TestAPIFallback:
105+
"""When JWT secret is not configured, fall back to Supabase API."""
106+
107+
def test_falls_back_to_api_when_no_secret(self):
108+
with patch("services.auth.create_client") as mock_client:
109+
client = MagicMock()
110+
user = MagicMock()
111+
user.id = "api-user-123"
112+
user.email = "api@test.com"
113+
user.user_metadata = {"tier": "free"}
114+
response = MagicMock()
115+
response.user = user
116+
client.auth.get_user.return_value = response
117+
mock_client.return_value = client
118+
119+
with patch.dict("os.environ", {
120+
"SUPABASE_URL": "https://test.supabase.co",
121+
"SUPABASE_ANON_KEY": "test-key",
122+
"SUPABASE_JWT_SECRET": "",
123+
}):
124+
from services.auth import SupabaseAuthService
125+
service = SupabaseAuthService()
126+
result = service.verify_jwt("some-token")
127+
128+
assert result["user_id"] == "api-user-123"
129+
client.auth.get_user.assert_called_once_with("some-token")

0 commit comments

Comments
 (0)