fix: 增强云端 Supabase 认证可靠性,修复验证码失败可观测性
- JWT 验证器新增 apikey 参数,支持云端 JWKS 认证头 - Auth 网关新增上游超时/错误映射为 503 状态码 - Auth 网关新增重定向 URL 校验,阻断开放重定向风险 - 用户依赖传递 anon_key 给 JWT 验证器 - 新增相关单元测试覆盖 JWKS 头、503 映射、重定向校验 - 新增实现计划文档
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user