fix: 增强云端 Supabase 认证可靠性,修复验证码失败可观测性

- JWT 验证器新增 apikey 参数,支持云端 JWKS 认证头
- Auth 网关新增上游超时/错误映射为 503 状态码
- Auth 网关新增重定向 URL 校验,阻断开放重定向风险
- 用户依赖传递 anon_key 给 JWT 验证器
- 新增相关单元测试覆盖 JWKS 头、503 映射、重定向校验
- 新增实现计划文档
This commit is contained in:
zl-q
2026-03-10 09:11:27 +08:00
parent 6fe2e7b6c3
commit c9a2c75c35
10 changed files with 384 additions and 75 deletions
+141 -7
View File
@@ -10,6 +10,10 @@ from v1.auth.gateway import SupabaseAuthGateway
from v1.auth.schemas import (
PasswordResetConfirmRequest,
PasswordResetRequest,
SessionCreateRequest,
SessionRefreshRequest,
VerificationCreateRequest,
VerificationVerifyRequest,
VerificationResendRequest,
)
@@ -21,7 +25,9 @@ class TestSupabaseAuthGateway:
) -> tuple[SupabaseAuthGateway, MagicMock, MagicMock]:
mock_client = MagicMock()
mock_admin_client = MagicMock()
monkeypatch.setattr("v1.auth.gateway.supabase_service.get_client", lambda: mock_client)
monkeypatch.setattr(
"v1.auth.gateway.supabase_service.get_client", lambda: mock_client
)
monkeypatch.setattr(
"v1.auth.gateway.supabase_service.get_admin_client",
lambda: mock_admin_client,
@@ -41,6 +47,29 @@ class TestSupabaseAuthGateway:
mock_reset_email.assert_called_once_with("test@example.com")
@pytest.mark.asyncio
async def test_create_verification_maps_timeout_error_to_503(
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
) -> None:
sut, mock_client, _ = gateway
from supabase import AuthError
mock_client.auth.sign_up = MagicMock(
side_effect=AuthError("request_timeout", None)
)
with pytest.raises(HTTPException) as exc_info:
await sut.create_verification(
VerificationCreateRequest(
username="tester",
email="test@example.com",
password="secret123",
)
)
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Auth service temporarily unavailable"
@pytest.mark.asyncio
async def test_request_password_reset_with_redirect(
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
@@ -61,17 +90,37 @@ class TestSupabaseAuthGateway:
)
@pytest.mark.asyncio
async def test_request_password_reset_rejects_redirect_outside_allowlist(
async def test_create_verification_rejects_untrusted_redirect_url(
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
) -> None:
sut, _, _ = gateway
request = PasswordResetRequest(
email="test@example.com",
redirect_to="https://evil.example/reset",
)
with pytest.raises(HTTPException) as exc_info:
await sut.request_password_reset(request)
await sut.create_verification(
VerificationCreateRequest(
username="tester",
email="test@example.com",
password="secret123",
redirect_to="https://evil.example.com/callback",
)
)
assert exc_info.value.status_code == 422
assert exc_info.value.detail == "Invalid redirect URL"
@pytest.mark.asyncio
async def test_request_password_reset_rejects_untrusted_redirect_url(
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
) -> None:
sut, _, _ = gateway
with pytest.raises(HTTPException) as exc_info:
await sut.request_password_reset(
PasswordResetRequest(
email="test@example.com",
redirect_to="https://evil.example.com/reset",
)
)
assert exc_info.value.status_code == 422
assert exc_info.value.detail == "Invalid redirect URL"
@@ -206,3 +255,88 @@ class TestSupabaseAuthGateway:
"test@example.com",
options={"redirect_to": "http://localhost:3000/reset-password"},
)
@pytest.mark.asyncio
async def test_verify_verification_maps_internal_error_to_503(
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
) -> None:
sut, mock_client, _ = gateway
from supabase import AuthError
mock_client.auth.verify_otp = MagicMock(
side_effect=AuthError("internal_server_error", None)
)
with pytest.raises(HTTPException) as exc_info:
await sut.verify_verification(
VerificationVerifyRequest(
type="signup",
email="test@example.com",
token="123456",
)
)
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Auth service temporarily unavailable"
@pytest.mark.asyncio
async def test_create_session_maps_internal_error_to_503(
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
) -> None:
sut, mock_client, _ = gateway
from supabase import AuthError
mock_client.auth.sign_in_with_password = MagicMock(
side_effect=AuthError("internal_server_error", None)
)
with pytest.raises(HTTPException) as exc_info:
await sut.create_session(
SessionCreateRequest(
email="test@example.com",
password="secret123",
)
)
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Auth service temporarily unavailable"
@pytest.mark.asyncio
async def test_refresh_session_maps_bad_gateway_to_503(
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
) -> None:
sut, mock_client, _ = gateway
from supabase import AuthError
mock_client.auth.refresh_session = MagicMock(
side_effect=AuthError("bad_gateway", None)
)
with pytest.raises(HTTPException) as exc_info:
await sut.refresh_session(SessionRefreshRequest(refresh_token="rt"))
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Auth service temporarily unavailable"
@pytest.mark.asyncio
async def test_confirm_password_reset_maps_service_unavailable_to_503(
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
) -> None:
sut, mock_client, _ = gateway
from supabase import AuthError
mock_client.auth.verify_otp = MagicMock(
side_effect=AuthError("service_unavailable", None)
)
with pytest.raises(HTTPException) as exc_info:
await sut.confirm_password_reset(
PasswordResetConfirmRequest(
email="test@example.com",
token="123456",
new_password="newpassword123",
)
)
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Auth service temporarily unavailable"