@@ -57,9 +57,9 @@ def client():
5757 return TestClient (_make_test_app ())
5858
5959
60- def _make_fake_jwt_token (sub : str = "test-user" ) -> str :
61- """构造一个不验签的假 JWT token(仅用于 dev 模式解码测试 )"""
62- header = base64 .urlsafe_b64encode (json .dumps ({"alg" : "HS256" , "typ" : "JWT" }).encode ()).decode ().rstrip ("=" )
60+ def _make_fake_jwt_token (sub : str = "test-user" , alg : str = "HS256" ) -> str :
61+ """构造一个不验签的假 JWT token(header 含指定 alg,便于算法白名单解析 )"""
62+ header = base64 .urlsafe_b64encode (json .dumps ({"alg" : alg , "typ" : "JWT" }).encode ()).decode ().rstrip ("=" )
6363 payload = base64 .urlsafe_b64encode (json .dumps ({"sub" : sub }).encode ()).decode ().rstrip ("=" )
6464 signature = "fake-signature"
6565 return f"{ header } .{ payload } .{ signature } "
@@ -82,10 +82,11 @@ def _set_secret(self, monkeypatch):
8282
8383 def test_valid_bearer_token_decodes_user_id (self , client ):
8484 """有有效 Bearer token 时应正确解码并返回 user_id"""
85+ fake_token = _make_fake_jwt_token ()
8586 with patch ("jose.jwt.decode" , return_value = {"sub" : "test-user" }):
8687 response = client .get (
8788 "/api/v1/ai/test" ,
88- headers = {"Authorization" : "Bearer fake.jwt.token " },
89+ headers = {"Authorization" : f "Bearer { fake_token } " },
8990 )
9091 assert response .status_code == 200
9192 assert response .json () == {"ok" : True }
@@ -118,10 +119,11 @@ def test_expired_token_returns_401(self, client):
118119 """token 过期时应返回 401"""
119120 from jose import ExpiredSignatureError
120121
122+ fake_token = _make_fake_jwt_token ()
121123 with patch ("jose.jwt.decode" , side_effect = ExpiredSignatureError ("Token expired" )):
122124 response = client .get (
123125 "/api/v1/ai/test" ,
124- headers = {"Authorization" : "Bearer expired.jwt.token " },
126+ headers = {"Authorization" : f "Bearer { fake_token } " },
125127 )
126128 assert response .status_code == 401
127129 assert "过期" in response .json ()["detail" ]
@@ -130,25 +132,98 @@ def test_invalid_signature_returns_401(self, client):
130132 """token 签名验证失败时应返回 401"""
131133 from jose import JWTError
132134
135+ fake_token = _make_fake_jwt_token ()
133136 with patch ("jose.jwt.decode" , side_effect = JWTError ("Signature verification failed" )):
134137 response = client .get (
135138 "/api/v1/ai/test" ,
136- headers = {"Authorization" : "Bearer invalid.jwt.signature " },
139+ headers = {"Authorization" : f "Bearer { fake_token } " },
137140 )
138141 assert response .status_code == 401
139142 assert "验证失败" in response .json ()["detail" ]
140143
141144 def test_token_missing_sub_claim_returns_401 (self , client ):
142145 """token 中缺少 sub claim 时应返回 401"""
146+ fake_token = _make_fake_jwt_token ()
143147 with patch ("jose.jwt.decode" , return_value = {"exp" : 9999999999 }):
144148 response = client .get (
145149 "/api/v1/ai/test" ,
146- headers = {"Authorization" : "Bearer valid.but.no-sub.token " },
150+ headers = {"Authorization" : f "Bearer { fake_token } " },
147151 )
148152 assert response .status_code == 401
149153 assert "sub" in response .json ()["detail" ] or "用户标识" in response .json ()["detail" ]
150154
151155
156+ # ────────────────────────────────────────────────────────────
157+ # 自动适配模式(jwt_algorithm 未配置)
158+ # ────────────────────────────────────────────────────────────
159+
160+
161+ class TestJWTAutoMode :
162+ """SUPABASE_JWT_ALGORITHM 未配置时按 token 实际算法自动适配验证
163+
164+ GW-2#1 延伸:不猜默认算法(原硬编码 ES256 / 默认 HS256 都曾使另一
165+ 算法的项目全站 401),按 token header 的 alg 选用对应密钥材料。
166+ """
167+
168+ @pytest .fixture (autouse = True )
169+ def _set_auto_mode (self , monkeypatch ):
170+ """清空算法配置但保留对称密钥 —— 自动适配 + HS256 材料就绪"""
171+ monkeypatch .setitem (APP_CONFIG , "jwt_secret" , "test-secret-key" )
172+ monkeypatch .setitem (APP_CONFIG , "jwt_algorithm" , "" )
173+ monkeypatch .setitem (APP_CONFIG , "supabase_url" , "" )
174+
175+ def test_hs256_token_decodes_with_secret (self , client ):
176+ """自动模式下 HS256 token 应使用对称密钥验证通过"""
177+ fake_token = _make_fake_jwt_token (alg = "HS256" )
178+ with patch ("jose.jwt.decode" , return_value = {"sub" : "test-user" }):
179+ response = client .get (
180+ "/api/v1/ai/test" ,
181+ headers = {"Authorization" : f"Bearer { fake_token } " },
182+ )
183+ assert response .status_code == 200
184+ assert response .json () == {"ok" : True }
185+
186+ def test_es256_token_decodes_with_jwks (self , client ):
187+ """自动模式下 ES256 token 应走 JWKS 公钥验证(按 kid 匹配)"""
188+ from unittest .mock import AsyncMock
189+
190+ fake_token = _make_fake_jwt_token (alg = "ES256" )
191+ with patch (
192+ "middleware.auth._get_es256_public_key" ,
193+ new = AsyncMock (return_value = "es256-public-key" ),
194+ ), patch ("jose.jwt.decode" , return_value = {"sub" : "test-user" }):
195+ response = client .get (
196+ "/api/v1/ai/test" ,
197+ headers = {"Authorization" : f"Bearer { fake_token } " },
198+ )
199+ assert response .status_code == 200
200+ assert response .json () == {"ok" : True }
201+
202+ def test_unsupported_alg_returns_401 (self , client ):
203+ """自动模式下白名单之外的算法(如 none)应 401"""
204+ fake_token = _make_fake_jwt_token (alg = "none" )
205+ response = client .get (
206+ "/api/v1/ai/test" ,
207+ headers = {"Authorization" : f"Bearer { fake_token } " },
208+ )
209+ assert response .status_code == 401
210+ assert "不支持的签名算法" in response .json ()["detail" ]
211+
212+ def test_garbage_token_returns_401 (self , client ):
213+ """自动模式下无法解析算法头的 token 应 401(fail-closed)"""
214+ response = client .get (
215+ "/api/v1/ai/test" ,
216+ headers = {"Authorization" : "Bearer not-a-jwt" },
217+ )
218+ assert response .status_code == 401
219+
220+ def test_no_authorization_header_returns_401 (self , client ):
221+ """自动模式下无 Authorization 头仍应 401(fail-closed,不降级放行)"""
222+ response = client .get ("/api/v1/ai/test" )
223+ assert response .status_code == 401
224+ assert "Authorization" in response .json ()["detail" ]
225+
226+
152227# ────────────────────────────────────────────────────────────
153228# 开发降级模式(jwt_secret 为空)
154229# ────────────────────────────────────────────────────────────
0 commit comments