From 9515fda9d22722deb800c622acc6e9697e136a86 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Fri, 27 Feb 2026 19:45:02 -0500 Subject: [PATCH 1/2] fix: auth resilience -- graceful JWT fallback, placeholder detection, postmortem tests Production auth outage: SUPABASE_JWT_SECRET was a placeholder ('dev-secret-key') on Railway. PR #253 switched JWT verification from Supabase API calls to local decode. Local decode against a wrong secret silently failed, causing 401 for all authenticated users. Layer 1 -- Graceful fallback (services/auth.py): When local JWT decode fails with InvalidTokenError, try Supabase API verification before giving up. Wrong secret now degrades to slow auth (API call per request) instead of broken auth (401). Expired tokens and missing claims still fail immediately (no point retrying those). Layer 2 -- Startup validation: - auth.py __init__: detects placeholder secrets (< 32 chars or known placeholders like 'dev-secret-key'). Nulls them out and logs error with instructions to get the real secret from Supabase dashboard. Forces API verification path. - startup_checks.py: warns if JWT secret is suspiciously short. Layer 3 -- Postmortem tests (3 new): - test_wrong_secret_falls_back_to_api: THE production scenario. JWT secret is set but wrong. Verifies auth succeeds via API fallback instead of returning 401. - test_expired_token_does_not_fallback: expired is expired. Verifies we don't waste an API call on genuinely expired tokens. - test_placeholder_secret_nulled_at_startup: verifies 'dev-secret-key' gets detected and nulled at init time. Updated 2 existing tests: - test_wrong_secret_raises_error -> test_wrong_secret_falls_back_not_raises - test_wrong_audience_raises_error -> test_wrong_audience_falls_back_not_raises Both now verify fallback behavior instead of asserting errors. 292 tests pass (289 + 3 new). Zero flake8 errors. --- backend/config/startup_checks.py | 11 ++ backend/services/auth.py | 38 ++++++- backend/tests/test_jwt_local_decode.py | 146 +++++++++++++++++++++++-- 3 files changed, 182 insertions(+), 13 deletions(-) diff --git a/backend/config/startup_checks.py b/backend/config/startup_checks.py index 6616784..5aa0e60 100644 --- a/backend/config/startup_checks.py +++ b/backend/config/startup_checks.py @@ -53,4 +53,15 @@ def validate_environment() -> None: if not os.getenv(var_name): logger.warning(f"{var_name} not set ({description}). {fallback_msg}") + # Validate JWT secret looks real (not a placeholder) + jwt_secret = os.getenv("SUPABASE_JWT_SECRET", "") + if jwt_secret and len(jwt_secret) < 32: + logger.error( + "SUPABASE_JWT_SECRET is too short -- likely a placeholder. " + "Real Supabase JWT secrets are 40+ characters. " + "Auth will fall back to slow API verification until this is fixed. " + "Get the real secret: Supabase dashboard -> Settings -> API -> JWT Secret", + secret_length=len(jwt_secret), + ) + logger.info("Environment validation passed") diff --git a/backend/services/auth.py b/backend/services/auth.py index 49b222e..24a5224 100644 --- a/backend/services/auth.py +++ b/backend/services/auth.py @@ -30,6 +30,19 @@ def __init__(self): if not self.jwt_secret: logger.warning("SUPABASE_JWT_SECRET not set -- falling back to API-based verification") + elif len(self.jwt_secret) < 32 or self.jwt_secret in ( + "dev-secret-key", "secret", "your-jwt-secret", "change-me", + "test-secret-key", "super-secret-jwt-token-with-at-least-32-characters", + ): + logger.error( + "SUPABASE_JWT_SECRET looks like a placeholder. " + "Local JWT verification will fail for real Supabase tokens. " + "Get the real secret from Supabase dashboard -> Settings -> API -> JWT Secret. " + "Falling back to API-based verification until fixed.", + secret_length=len(self.jwt_secret), + ) + # Null out the secret so verify_jwt takes the API path directly + self.jwt_secret = None self.client: Client = create_client(self.supabase_url, self.supabase_key) @@ -38,16 +51,33 @@ def verify_jwt(self, token: str) -> Dict[str, Any]: Verify Supabase JWT token locally using the signing secret. No network call required -- instant verification using HS256. - Falls back to Supabase API call if JWT_SECRET is not configured. + Falls back to Supabase API call if JWT_SECRET is not configured, + or if local decode fails (wrong secret, config mismatch). """ if token.lower().startswith("bearer "): token = token[7:] - # local decode when secret is available (fast path, no network) + # Local decode when secret is available (fast path, no network) if self.jwt_secret: - return self._verify_local(token) + try: + return self._verify_local(token) + except TokenExpiredError: + # Expired is expired -- no point retrying via API + raise + except TokenMissingClaimError: + # Token decoded but missing required claims -- won't help to retry + raise + except InvalidTokenError: + # Could be wrong secret, config mismatch, or genuinely bad token. + # Try API verification before giving up -- a wrong JWT_SECRET + # should degrade to slow auth, not broken auth. + logger.warning( + "Local JWT decode failed, falling back to API verification. " + "Check SUPABASE_JWT_SECRET if this persists." + ) + return self._verify_via_api(token) - # fallback: API call to Supabase (slow path, requires network) + # No secret configured -- API call to Supabase (slow path) return self._verify_via_api(token) def _verify_local(self, token: str) -> Dict[str, Any]: diff --git a/backend/tests/test_jwt_local_decode.py b/backend/tests/test_jwt_local_decode.py index c525e5f..e7d3590 100644 --- a/backend/tests/test_jwt_local_decode.py +++ b/backend/tests/test_jwt_local_decode.py @@ -5,7 +5,7 @@ from unittest.mock import patch, MagicMock -JWT_SECRET = "test-jwt-secret-for-unit-tests" +JWT_SECRET = "test-jwt-secret-for-unit-tests-must-be-32-chars-or-longer" def _make_token(payload: dict, secret: str = JWT_SECRET) -> str: @@ -57,12 +57,22 @@ def test_expired_token_raises_error(self, auth_service): with pytest.raises(TokenExpiredError): auth_service.verify_jwt(token) - def test_wrong_secret_raises_error(self, auth_service): + def test_wrong_secret_falls_back_not_raises(self, auth_service): + """Wrong secret should trigger API fallback, not raise.""" token = _make_token({"sub": "user-000"}, secret="wrong-secret") - from services.exceptions import InvalidTokenError - with pytest.raises(InvalidTokenError): - auth_service.verify_jwt(token) + # Set up API fallback mock + user = MagicMock() + user.id = "user-000" + user.email = "fallback@test.com" + user.user_metadata = {} + response = MagicMock() + response.user = user + auth_service.client.auth.get_user.return_value = response + + result = auth_service.verify_jwt(token) + assert result["user_id"] == "user-000" + auth_service.client.auth.get_user.assert_called_once() def test_missing_sub_claim_raises_error(self, auth_service): token = _make_token({"email": "no-sub@test.com"}) @@ -71,13 +81,23 @@ def test_missing_sub_claim_raises_error(self, auth_service): with pytest.raises(TokenMissingClaimError): auth_service.verify_jwt(token) - def test_wrong_audience_raises_error(self, auth_service): + def test_wrong_audience_falls_back_not_raises(self, auth_service): + """Wrong audience should trigger API fallback, not raise.""" payload = {"sub": "user-aud", "aud": "wrong-audience", "exp": int(time.time()) + 3600} token = pyjwt.encode(payload, JWT_SECRET, algorithm="HS256") - from services.exceptions import InvalidTokenError - with pytest.raises(InvalidTokenError): - auth_service.verify_jwt(token) + # Set up API fallback mock + user = MagicMock() + user.id = "user-aud" + user.email = "aud@test.com" + user.user_metadata = {} + response = MagicMock() + response.user = user + auth_service.client.auth.get_user.return_value = response + + result = auth_service.verify_jwt(token) + assert result["user_id"] == "user-aud" + auth_service.client.auth.get_user.assert_called_once() def test_no_network_call_made(self, auth_service): """The whole point of OPE-75: verify_jwt should NOT hit the network.""" @@ -134,3 +154,111 @@ def test_falls_back_to_api_when_no_secret(self): assert result["user_id"] == "api-user-123" client.auth.get_user.assert_called_once_with("some-token") + + def test_wrong_secret_falls_back_to_api(self): + """ + POSTMORTEM TEST -- Feb 2026 production auth outage. + + Scenario: SUPABASE_JWT_SECRET is set but WRONG (doesn't match + what Supabase uses to sign tokens). Local decode fails. + System MUST fall back to API verification instead of returning 401. + + Before this fix, wrong secret = broken auth for all users. + After this fix, wrong secret = slow auth (API call) but working. + """ + with patch("services.auth.create_client") as mock_client: + client = MagicMock() + user = MagicMock() + user.id = "fallback-user-456" + user.email = "fallback@test.com" + user.user_metadata = {"tier": "pro"} + response = MagicMock() + response.user = user + client.auth.get_user.return_value = response + mock_client.return_value = client + + with patch.dict("os.environ", { + "SUPABASE_URL": "https://test.supabase.co", + "SUPABASE_ANON_KEY": "test-key", + # Real-length secret but WRONG -- simulates production mismatch + "SUPABASE_JWT_SECRET": "a" * 64, + }): + from services.auth import SupabaseAuthService + service = SupabaseAuthService() + + # Token signed with a DIFFERENT secret (like Supabase would) + token = _make_token( + {"sub": "fallback-user-456", "email": "fallback@test.com"}, + secret="the-real-supabase-secret-that-we-dont-have", + ) + + result = service.verify_jwt(token) + + # Should succeed via API fallback, not 401 + assert result["user_id"] == "fallback-user-456" + assert result["email"] == "fallback@test.com" + # Verify it actually used the API path + client.auth.get_user.assert_called_once() + + def test_expired_token_does_not_fallback(self): + """Expired tokens should NOT try API fallback -- expired is expired.""" + with patch("services.auth.create_client") as mock_client: + mock_client.return_value = MagicMock() + + with patch.dict("os.environ", { + "SUPABASE_URL": "https://test.supabase.co", + "SUPABASE_ANON_KEY": "test-key", + "SUPABASE_JWT_SECRET": JWT_SECRET, + }): + from services.auth import SupabaseAuthService + service = SupabaseAuthService() + + token = _make_token( + {"sub": "expired-user"}, + secret=JWT_SECRET, + ) + # Manually create expired token + expired_token = pyjwt.encode( + {"sub": "expired-user", "aud": "authenticated", + "exp": int(time.time()) - 120, "iat": int(time.time()) - 3720}, + JWT_SECRET, algorithm="HS256", + ) + + from services.exceptions import TokenExpiredError + with pytest.raises(TokenExpiredError): + service.verify_jwt(expired_token) + + # Should NOT have tried API fallback + service.client.auth.get_user.assert_not_called() + + def test_placeholder_secret_nulled_at_startup(self): + """ + Placeholder secrets like 'dev-secret-key' should be detected + at init time and nulled out, forcing API verification path. + """ + with patch("services.auth.create_client") as mock_client: + client = MagicMock() + user = MagicMock() + user.id = "placeholder-user" + user.email = "ph@test.com" + user.user_metadata = {} + response = MagicMock() + response.user = user + client.auth.get_user.return_value = response + mock_client.return_value = client + + with patch.dict("os.environ", { + "SUPABASE_URL": "https://test.supabase.co", + "SUPABASE_ANON_KEY": "test-key", + "SUPABASE_JWT_SECRET": "dev-secret-key", + }): + from services.auth import SupabaseAuthService + service = SupabaseAuthService() + + # Secret should have been nulled out + assert service.jwt_secret is None + + # Should work via API path directly + result = service.verify_jwt("any-token") + assert result["user_id"] == "placeholder-user" + client.auth.get_user.assert_called_once() From 89e0553ae0e982f0b669bbd05d18966193fbe0e3 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Fri, 27 Feb 2026 19:51:58 -0500 Subject: [PATCH 2/2] fix: remove dead token variable in expired_token_does_not_fallback test --- backend/tests/test_jwt_local_decode.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/backend/tests/test_jwt_local_decode.py b/backend/tests/test_jwt_local_decode.py index e7d3590..2fe4a6e 100644 --- a/backend/tests/test_jwt_local_decode.py +++ b/backend/tests/test_jwt_local_decode.py @@ -213,11 +213,6 @@ def test_expired_token_does_not_fallback(self): from services.auth import SupabaseAuthService service = SupabaseAuthService() - token = _make_token( - {"sub": "expired-user"}, - secret=JWT_SECRET, - ) - # Manually create expired token expired_token = pyjwt.encode( {"sub": "expired-user", "aud": "authenticated", "exp": int(time.time()) - 120, "iat": int(time.time()) - 3720},