File size: 16,333 Bytes
9b403c4 8211361 9b403c4 8211361 9b403c4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 | """只用官方 HTTP 形状的 fixture 验证 Supabase 适配器,不创建外部账号或凭据。"""
from __future__ import annotations
import json
import logging
from pathlib import Path
import sys
import traceback
import unittest
import httpx
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "apps/market-api"))
from app.native.auth_supabase import AuthDenied, AuthUnavailable, AuthRateLimited, RECOVERY_CALLBACK, RECOVERY_MESSAGE, SupabaseAuth
URL = "https://fixtureproject.supabase.co"
KEY = "sb_publishable_fixture_not_a_secret"
ORIGIN = "https://message.example"
USER_ID = "123e4567-e89b-42d3-a456-426614174000"
EMAIL = "fixture@example.invalid"
ACCESS = "fixture.access.token"
REFRESH = "fixture-refresh-token"
PASSWORD = "fixture-password-only"
VERIFIER = "v" * 64
CHALLENGE = "c" * 43
def identity(**changes):
return {"id": USER_ID, "email": EMAIL, "role": "authenticated", "is_anonymous": False, **changes}
def session(**changes):
return {"access_token": ACCESS, "refresh_token": REFRESH, "expires_in": 3600, "token_type": "bearer", "user": identity(), **changes}
class SupabaseAuthTests(unittest.IsolatedAsyncioTestCase):
def adapter(self, handler):
client = httpx.AsyncClient(transport=httpx.MockTransport(handler), follow_redirects=True,
headers={"Authorization": "Bearer unrelated-client-secret", "X-Unrelated": "private"},
cookies={"unrelated": "private"}, auth=("other-user", "other-password"))
self.addAsyncCleanup(client.aclose)
return SupabaseAuth(client, URL, KEY, ORIGIN)
async def test_login_uses_official_endpoint_and_drops_unrelated_client_credentials(self):
requests = []
def handler(request):
requests.append(request)
self.assertEqual(request.method, "POST")
self.assertEqual(str(request.url), URL + "/auth/v1/token?grant_type=password")
self.assertEqual(json.loads(request.content), {"email": EMAIL, "password": "short"})
self.assertEqual(request.headers["apikey"], KEY)
for name in ("authorization", "cookie", "x-unrelated"):
self.assertNotIn(name, request.headers)
return httpx.Response(200, json=session(provider_token="must-drop", user=identity(user_metadata={"admin": True})))
result = await self.adapter(handler).login(EMAIL, "short")
self.assertEqual(result, {"access_token": ACCESS, "refresh_token": REFRESH, "expires_in": 3600, "user": {"id": USER_ID, "email": EMAIL}})
self.assertEqual(len(requests), 1)
async def test_identity_and_admin_membership_are_checked_on_every_request(self):
requests, checks = [], []
def handler(request):
requests.append(request)
self.assertEqual(request.headers["Authorization"], "Bearer " + ACCESS)
self.assertEqual(request.headers["apikey"], KEY)
if request.url.path == "/auth/v1/user":
return httpx.Response(200, json=identity(user_metadata={"admin": True}, app_metadata={"role": "admin"}))
self.assertEqual(request.url.path, "/rest/v1/rpc/message_current_admin")
self.assertEqual(request.method, "POST")
self.assertEqual(json.loads(request.content), {})
checks.append(True)
return httpx.Response(200, json=len(checks) == 1)
auth = self.adapter(handler)
self.assertEqual(await auth.user(ACCESS), {"id": USER_ID, "email": EMAIL})
self.assertIsNone(await auth.user(ACCESS), "撤销 membership 或会话必须在下一次请求拒绝")
self.assertEqual(len(requests), 4)
async def test_anonymous_and_service_identities_are_never_admins(self):
for changed in ({"is_anonymous": True}, {"is_anonymous": None}, {"role": "anon"}, {"role": "service_role"}):
requests = []
def handler(request):
requests.append(request)
return httpx.Response(200, json=identity(**changed, user_metadata={"admin": True}))
with self.subTest(changed=changed):
self.assertIsNone(await self.adapter(handler).user(ACCESS))
self.assertEqual(len(requests), 1)
async def test_invalid_or_revoked_session_is_denied_without_leaking_response(self):
for status in (400, 401, 403, 422):
with self.subTest(status=status):
auth = self.adapter(lambda _: httpx.Response(status, json={"message": ACCESS, "error_code": "session_not_found"}))
self.assertIsNone(await auth.user(ACCESS))
async def test_false_membership_cannot_be_overridden_by_metadata(self):
def handler(request):
if request.url.path == "/auth/v1/user":
return httpx.Response(200, json=identity(user_metadata={"role": "admin"}, app_metadata={"role": "admin"}))
return httpx.Response(200, json=False)
self.assertIsNone(await self.adapter(handler).user(ACCESS))
async def test_recovery_identity_uses_separate_permission_rpc(self):
requested = []
def handler(request):
requested.append(request.url.path)
if request.url.path == "/auth/v1/user":
return httpx.Response(200, json=identity())
return httpx.Response(200, json=request.url.path == "/rest/v1/rpc/message_recovery_admin")
auth = self.adapter(handler)
self.assertIsNone(await auth.user(ACCESS))
self.assertEqual(await auth.user(ACCESS, recovery=True), {"id": USER_ID, "email": EMAIL})
self.assertEqual(requested, ["/auth/v1/user", "/rest/v1/rpc/message_current_admin", "/auth/v1/user", "/rest/v1/rpc/message_recovery_admin"])
async def test_malformed_membership_never_becomes_truthy_authorization(self):
for value in (None, 1, "true", {}, [{"message_current_admin": True}]):
def handler(request):
return httpx.Response(200, json=identity() if request.url.path == "/auth/v1/user" else value)
with self.subTest(value=value), self.assertRaises(AuthUnavailable):
await self.adapter(handler).user(ACCESS)
async def test_missing_rpc_is_an_unavailable_service_not_a_non_admin(self):
def handler(request):
return httpx.Response(200, json=identity()) if request.url.path == "/auth/v1/user" else httpx.Response(404, text="function absent")
with self.assertRaises(AuthUnavailable):
await self.adapter(handler).user(ACCESS)
async def test_identity_requires_canonical_nonzero_uuid(self):
for value in ("first-user", "00000000-0000-0000-0000-000000000000", USER_ID.upper(), None, 3):
with self.subTest(value=value), self.assertRaises(AuthUnavailable):
await self.adapter(lambda _: httpx.Response(200, json=identity(id=value))).user(ACCESS)
async def test_refresh_uses_single_request_and_returns_only_session_fields(self):
requests = []
def handler(request):
requests.append(request)
self.assertEqual(request.url.params["grant_type"], "refresh_token")
self.assertEqual(json.loads(request.content), {"refresh_token": REFRESH})
self.assertNotIn("authorization", request.headers)
return httpx.Response(200, json=session(refresh_token="fixture-rotated-refresh"))
result = await self.adapter(handler).refresh(REFRESH)
self.assertEqual(result["refresh_token"], "fixture-rotated-refresh")
self.assertEqual(len(requests), 1)
async def test_invalid_upstream_sessions_are_rejected(self):
for changed in ({"access_token": ""}, {"access_token": "bad\nheader"}, {"refresh_token": None}, {"expires_in": 0}, {"expires_in": True}, {"expires_in": 86401}, {"token_type": "MAC"}, {"user": {}}):
with self.subTest(changed=changed), self.assertRaises((AuthUnavailable, AuthDenied)):
await self.adapter(lambda _: httpx.Response(200, json=session(**changed))).login(EMAIL, PASSWORD)
async def test_recovery_uses_s256_and_fixed_public_origin_callback(self):
requests = []
def handler(request):
requests.append(request)
self.assertEqual(request.url.path, "/auth/v1/recover")
self.assertEqual(request.url.params["redirect_to"], ORIGIN + RECOVERY_CALLBACK)
self.assertEqual(json.loads(request.content), {"email": EMAIL, "code_challenge": CHALLENGE, "code_challenge_method": "s256"})
self.assertNotIn(EMAIL, str(request.url))
self.assertNotIn(VERIFIER, str(request.url))
return httpx.Response(200, json={})
self.assertEqual(await self.adapter(handler).recover(EMAIL, CHALLENGE), RECOVERY_MESSAGE)
self.assertEqual(len(requests), 1)
async def test_recovery_code_and_verifier_are_only_in_post_body(self):
code = "fixture-single-use-code"
def handler(request):
self.assertEqual(request.method, "POST")
self.assertEqual(str(request.url), URL + "/auth/v1/token?grant_type=pkce")
self.assertEqual(json.loads(request.content), {"auth_code": code, "code_verifier": VERIFIER})
self.assertNotIn(code, str(request.url))
self.assertNotIn(VERIFIER, str(request.url))
return httpx.Response(200, json=session())
self.assertEqual((await self.adapter(handler).exchange_recovery(code, VERIFIER))["user"]["id"], USER_ID)
async def test_password_update_is_only_a_supabase_user_update(self):
def handler(request):
self.assertEqual(request.method, "PUT")
self.assertEqual(request.url.path, "/auth/v1/user")
self.assertEqual(json.loads(request.content), {"password": PASSWORD})
self.assertEqual(request.headers["authorization"], "Bearer " + ACCESS)
return httpx.Response(200, json=identity())
self.assertEqual(await self.adapter(handler).update_password(ACCESS, PASSWORD), {"id": USER_ID, "email": EMAIL})
async def test_logout_is_scoped_to_current_supabase_session(self):
def handler(request):
self.assertEqual(request.method, "POST")
self.assertEqual(str(request.url), URL + "/auth/v1/logout?scope=local")
self.assertEqual(request.headers["authorization"], "Bearer " + ACCESS)
return httpx.Response(204)
self.assertIsNone(await self.adapter(handler).logout(ACCESS))
async def test_redirects_never_receive_credentials(self):
requests = []
def handler(request):
requests.append(request)
return httpx.Response(307, headers={"location": "https://attacker.invalid/collect"})
with self.assertRaises(AuthUnavailable):
await self.adapter(handler).login(EMAIL, PASSWORD)
self.assertEqual(len(requests), 1)
self.assertEqual(requests[0].url.host, "fixtureproject.supabase.co")
async def test_denial_rate_limit_and_network_errors_never_expose_secrets(self):
leaked = " ".join((PASSWORD, ACCESS, REFRESH, EMAIL, VERIFIER))
for status in (400, 401, 403, 422, 429, 500):
expected = AuthDenied if status in {400, 401, 403, 422} else AuthUnavailable
with self.subTest(status=status):
auth = self.adapter(lambda _: httpx.Response(status, json={"error": leaked}))
try:
await auth.login(EMAIL, PASSWORD)
except expected as error:
self.assertNotIn(leaked, "".join(traceback.format_exception(error)))
else:
self.fail("上游失败必须拒绝")
calls = []
def timeout(request):
calls.append(request)
raise httpx.ReadTimeout(leaked, request=request)
try:
await self.adapter(timeout).refresh(REFRESH)
except AuthUnavailable as error:
self.assertNotIn(leaked, "".join(traceback.format_exception(error)))
else:
self.fail("刷新超时必须失败关闭")
self.assertEqual(len(calls), 1, "不得自动重复刷新单次令牌")
async def test_malformed_json_and_logout_failure_have_generic_errors(self):
with self.assertRaises(AuthUnavailable) as raised:
await self.adapter(lambda _: httpx.Response(200, text=ACCESS)).login(EMAIL, PASSWORD)
self.assertNotIn(ACCESS, "".join(traceback.format_exception(raised.exception)))
with self.assertRaises(AuthDenied) as raised:
await self.adapter(lambda _: httpx.Response(403, json={"message": ACCESS})).logout(ACCESS)
self.assertNotIn(ACCESS, str(raised.exception))
async def test_secret_bodies_do_not_appear_in_http_client_logs(self):
with self.assertLogs("httpx", level=logging.INFO) as logs:
await self.adapter(lambda _: httpx.Response(200, json=session())).login(EMAIL, PASSWORD)
emitted = "\n".join(logs.output)
for secret in (PASSWORD, EMAIL, ACCESS, REFRESH):
self.assertNotIn(secret, emitted)
async def test_invalid_inputs_do_not_make_network_requests(self):
requests = []
def handler(request):
requests.append(request)
return httpx.Response(200, json=session())
auth = self.adapter(handler)
for operation in (auth.login(EMAIL, ""), auth.login(EMAIL, "x" * 257), auth.refresh("bad\r\nheader"), auth.exchange_recovery("code", "short"), auth.recover(EMAIL, "bad"), auth.update_password(ACCESS, "short"), auth.update_password(ACCESS, "x" * 257)):
with self.assertRaises(AuthDenied):
await operation
self.assertIsNone(await auth.user("bad\nheader"))
self.assertEqual(requests, [])
async def test_constructor_rejects_credential_destination_confusion(self):
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200)))
self.addAsyncCleanup(client.aclose)
for url in ("http://fixture.supabase.co", "https://supabase.co", "https://fixture.supabase.co.evil", "https://nested.fixture.supabase.co", "https://user@fixture.supabase.co", URL + "/auth/v1", URL + "?key=secret", URL + "#fragment", URL + ":443"):
with self.subTest(url=url), self.assertRaises(ValueError):
SupabaseAuth(client, url, KEY, ORIGIN)
for key in ("sb_secret_fixture", "service_role", "sb_publishable_bad\nheader", ""):
with self.subTest(key=key), self.assertRaises(ValueError):
SupabaseAuth(client, URL, key, ORIGIN)
for origin in ("http://external.example", ORIGIN + "/path", ORIGIN + "?next=bad", "https://user:password@message.example", "https://message.example\\evil", "https://message.example:bad"):
with self.subTest(origin=origin), self.assertRaises(ValueError):
SupabaseAuth(client, URL, KEY, origin)
for origin in (ORIGIN, ORIGIN + "/", "http://127.0.0.1:8000", "http://localhost:8000", "http://[::1]:8000"):
SupabaseAuth(client, URL + "/", KEY, origin)
async def test_recovery_rate_limit_has_clear_safe_message_and_retry_header(self):
adapter = self.adapter(lambda _request: httpx.Response(429, headers={'Retry-After': '120'}, json={'code':'over_email_send_rate_limit', 'msg':'fixture-password-and-secret-must-not-leak'}))
with self.assertRaises(AuthRateLimited) as caught:
await adapter.recover(EMAIL, CHALLENGE)
self.assertEqual(caught.exception.status_code, 429)
self.assertEqual(caught.exception.retry_after, '120')
self.assertIn('邮件发送过于频繁', str(caught.exception))
self.assertNotIn('fixture-password', str(caught.exception))
async def test_rate_limit_does_not_invent_wait_time(self):
adapter = self.adapter(lambda _request: httpx.Response(429, json={'code':'over_email_send_rate_limit'}))
with self.assertRaises(AuthRateLimited) as caught:
await adapter.recover(EMAIL, CHALLENGE)
self.assertIsNone(caught.exception.retry_after)
for header in ('99999999', 'invalid', '-1', '0'):
self.assertIsNone(AuthRateLimited(retry_after=header).retry_after)
if __name__ == "__main__":
unittest.main()
|