55from unittest .mock import patch , MagicMock
66
77
8- JWT_SECRET = "test-jwt-secret-for-unit-tests"
8+ JWT_SECRET = "test-jwt-secret-for-unit-tests-must-be-32-chars-or-longer "
99
1010
1111def _make_token (payload : dict , secret : str = JWT_SECRET ) -> str :
@@ -57,12 +57,22 @@ def test_expired_token_raises_error(self, auth_service):
5757 with pytest .raises (TokenExpiredError ):
5858 auth_service .verify_jwt (token )
5959
60- def test_wrong_secret_raises_error (self , auth_service ):
60+ def test_wrong_secret_falls_back_not_raises (self , auth_service ):
61+ """Wrong secret should trigger API fallback, not raise."""
6162 token = _make_token ({"sub" : "user-000" }, secret = "wrong-secret" )
6263
63- from services .exceptions import InvalidTokenError
64- with pytest .raises (InvalidTokenError ):
65- auth_service .verify_jwt (token )
64+ # Set up API fallback mock
65+ user = MagicMock ()
66+ user .id = "user-000"
67+ user .email = "fallback@test.com"
68+ user .user_metadata = {}
69+ response = MagicMock ()
70+ response .user = user
71+ auth_service .client .auth .get_user .return_value = response
72+
73+ result = auth_service .verify_jwt (token )
74+ assert result ["user_id" ] == "user-000"
75+ auth_service .client .auth .get_user .assert_called_once ()
6676
6777 def test_missing_sub_claim_raises_error (self , auth_service ):
6878 token = _make_token ({"email" : "no-sub@test.com" })
@@ -71,13 +81,23 @@ def test_missing_sub_claim_raises_error(self, auth_service):
7181 with pytest .raises (TokenMissingClaimError ):
7282 auth_service .verify_jwt (token )
7383
74- def test_wrong_audience_raises_error (self , auth_service ):
84+ def test_wrong_audience_falls_back_not_raises (self , auth_service ):
85+ """Wrong audience should trigger API fallback, not raise."""
7586 payload = {"sub" : "user-aud" , "aud" : "wrong-audience" , "exp" : int (time .time ()) + 3600 }
7687 token = pyjwt .encode (payload , JWT_SECRET , algorithm = "HS256" )
7788
78- from services .exceptions import InvalidTokenError
79- with pytest .raises (InvalidTokenError ):
80- auth_service .verify_jwt (token )
89+ # Set up API fallback mock
90+ user = MagicMock ()
91+ user .id = "user-aud"
92+ user .email = "aud@test.com"
93+ user .user_metadata = {}
94+ response = MagicMock ()
95+ response .user = user
96+ auth_service .client .auth .get_user .return_value = response
97+
98+ result = auth_service .verify_jwt (token )
99+ assert result ["user_id" ] == "user-aud"
100+ auth_service .client .auth .get_user .assert_called_once ()
81101
82102 def test_no_network_call_made (self , auth_service ):
83103 """The whole point of OPE-75: verify_jwt should NOT hit the network."""
@@ -134,3 +154,106 @@ def test_falls_back_to_api_when_no_secret(self):
134154
135155 assert result ["user_id" ] == "api-user-123"
136156 client .auth .get_user .assert_called_once_with ("some-token" )
157+
158+ def test_wrong_secret_falls_back_to_api (self ):
159+ """
160+ POSTMORTEM TEST -- Feb 2026 production auth outage.
161+
162+ Scenario: SUPABASE_JWT_SECRET is set but WRONG (doesn't match
163+ what Supabase uses to sign tokens). Local decode fails.
164+ System MUST fall back to API verification instead of returning 401.
165+
166+ Before this fix, wrong secret = broken auth for all users.
167+ After this fix, wrong secret = slow auth (API call) but working.
168+ """
169+ with patch ("services.auth.create_client" ) as mock_client :
170+ client = MagicMock ()
171+ user = MagicMock ()
172+ user .id = "fallback-user-456"
173+ user .email = "fallback@test.com"
174+ user .user_metadata = {"tier" : "pro" }
175+ response = MagicMock ()
176+ response .user = user
177+ client .auth .get_user .return_value = response
178+ mock_client .return_value = client
179+
180+ with patch .dict ("os.environ" , {
181+ "SUPABASE_URL" : "https://test.supabase.co" ,
182+ "SUPABASE_ANON_KEY" : "test-key" ,
183+ # Real-length secret but WRONG -- simulates production mismatch
184+ "SUPABASE_JWT_SECRET" : "a" * 64 ,
185+ }):
186+ from services .auth import SupabaseAuthService
187+ service = SupabaseAuthService ()
188+
189+ # Token signed with a DIFFERENT secret (like Supabase would)
190+ token = _make_token (
191+ {"sub" : "fallback-user-456" , "email" : "fallback@test.com" },
192+ secret = "the-real-supabase-secret-that-we-dont-have" ,
193+ )
194+
195+ result = service .verify_jwt (token )
196+
197+ # Should succeed via API fallback, not 401
198+ assert result ["user_id" ] == "fallback-user-456"
199+ assert result ["email" ] == "fallback@test.com"
200+ # Verify it actually used the API path
201+ client .auth .get_user .assert_called_once ()
202+
203+ def test_expired_token_does_not_fallback (self ):
204+ """Expired tokens should NOT try API fallback -- expired is expired."""
205+ with patch ("services.auth.create_client" ) as mock_client :
206+ mock_client .return_value = MagicMock ()
207+
208+ with patch .dict ("os.environ" , {
209+ "SUPABASE_URL" : "https://test.supabase.co" ,
210+ "SUPABASE_ANON_KEY" : "test-key" ,
211+ "SUPABASE_JWT_SECRET" : JWT_SECRET ,
212+ }):
213+ from services .auth import SupabaseAuthService
214+ service = SupabaseAuthService ()
215+
216+ expired_token = pyjwt .encode (
217+ {"sub" : "expired-user" , "aud" : "authenticated" ,
218+ "exp" : int (time .time ()) - 120 , "iat" : int (time .time ()) - 3720 },
219+ JWT_SECRET , algorithm = "HS256" ,
220+ )
221+
222+ from services .exceptions import TokenExpiredError
223+ with pytest .raises (TokenExpiredError ):
224+ service .verify_jwt (expired_token )
225+
226+ # Should NOT have tried API fallback
227+ service .client .auth .get_user .assert_not_called ()
228+
229+ def test_placeholder_secret_nulled_at_startup (self ):
230+ """
231+ Placeholder secrets like 'dev-secret-key' should be detected
232+ at init time and nulled out, forcing API verification path.
233+ """
234+ with patch ("services.auth.create_client" ) as mock_client :
235+ client = MagicMock ()
236+ user = MagicMock ()
237+ user .id = "placeholder-user"
238+ user .email = "ph@test.com"
239+ user .user_metadata = {}
240+ response = MagicMock ()
241+ response .user = user
242+ client .auth .get_user .return_value = response
243+ mock_client .return_value = client
244+
245+ with patch .dict ("os.environ" , {
246+ "SUPABASE_URL" : "https://test.supabase.co" ,
247+ "SUPABASE_ANON_KEY" : "test-key" ,
248+ "SUPABASE_JWT_SECRET" : "dev-secret-key" ,
249+ }):
250+ from services .auth import SupabaseAuthService
251+ service = SupabaseAuthService ()
252+
253+ # Secret should have been nulled out
254+ assert service .jwt_secret is None
255+
256+ # Should work via API path directly
257+ result = service .verify_jwt ("any-token" )
258+ assert result ["user_id" ] == "placeholder-user"
259+ client .auth .get_user .assert_called_once ()
0 commit comments