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
@@ -17,6 +17,40 @@ from core.auth.jwt_verifier import (
)
def test_jwks_client_uses_supabase_auth_headers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
class _FakePyJWKClient:
def __init__(
self,
uri: str,
*,
headers: dict[str, Any] | None = None,
**_: Any,
) -> None:
captured["uri"] = uri
captured["headers"] = headers
monkeypatch.setattr("core.auth.jwt_verifier.jwt.PyJWKClient", _FakePyJWKClient)
JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer="https://example.supabase.co/auth/v1",
audience="authenticated",
apikey="anon-key-value",
)
assert (
captured["uri"] == "https://example.supabase.co/auth/v1/.well-known/jwks.json"
)
assert captured["headers"] == {
"apikey": "anon-key-value",
"Authorization": "Bearer anon-key-value",
}
def _set_jwks_client(verifier: JwtVerifier, client: Any) -> None:
cast(Any, verifier)._jwks_client = client
@@ -90,6 +124,7 @@ def test_verify_token_with_jwks_success() -> None:
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
apikey="anon-key",
)
_set_jwks_client(
verifier,
@@ -118,6 +153,7 @@ def test_verify_token_rejects_invalid_issuer() -> None:
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
apikey="anon-key",
)
_set_jwks_client(
verifier,
@@ -145,6 +181,7 @@ def test_verify_token_rejects_hs256_token() -> None:
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
apikey="anon-key",
)
_set_jwks_client(
verifier,
@@ -172,6 +209,7 @@ def test_verify_token_rejects_expired_token() -> None:
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
apikey="anon-key",
)
_set_jwks_client(
verifier,
@@ -199,6 +237,7 @@ def test_verify_token_rejects_invalid_audience() -> None:
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
apikey="anon-key",
)
_set_jwks_client(
verifier,
@@ -227,6 +266,7 @@ def test_verify_token_rejects_invalid_signature() -> None:
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
apikey="anon-key",
)
_set_jwks_client(
verifier,
@@ -254,6 +294,7 @@ def test_verify_token_maps_jwks_connection_error() -> None:
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
apikey="anon-key",
)
def _raise_connection_error(_: str) -> SimpleNamespace:
@@ -11,7 +11,9 @@ from services.base.supabase import SupabaseService
@pytest.mark.asyncio
async def test_initialize_success(monkeypatch: pytest.MonkeyPatch) -> None:
service = SupabaseService(settings=SupabaseSettings())
service = SupabaseService(
settings=SupabaseSettings(public_url="https://test.supabase.co")
)
anon_client = MagicMock()
admin_client = MagicMock()
@@ -34,7 +36,9 @@ async def test_initialize_success(monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.asyncio
async def test_initialize_failure(monkeypatch: pytest.MonkeyPatch) -> None:
service = SupabaseService(settings=SupabaseSettings())
service = SupabaseService(
settings=SupabaseSettings(public_url="https://test.supabase.co")
)
def _fake_create_client(_: str, __: str) -> object:
raise RuntimeError("boom")
@@ -51,7 +55,9 @@ async def test_initialize_failure(monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.asyncio
async def test_close_clears_clients(monkeypatch: pytest.MonkeyPatch) -> None:
service = SupabaseService(settings=SupabaseSettings())
service = SupabaseService(
settings=SupabaseSettings(public_url="https://test.supabase.co")
)
def _fake_create_client(_: str, __: str) -> object:
return MagicMock()
@@ -69,7 +75,9 @@ async def test_close_clears_clients(monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.asyncio
async def test_health_check_uninitialized() -> None:
service = SupabaseService(settings=SupabaseSettings())
service = SupabaseService(
settings=SupabaseSettings(public_url="https://test.supabase.co")
)
health = await service.health_check()
@@ -78,7 +86,9 @@ async def test_health_check_uninitialized() -> None:
@pytest.mark.asyncio
async def test_health_check_initialized(monkeypatch: pytest.MonkeyPatch) -> None:
service = SupabaseService(settings=SupabaseSettings())
service = SupabaseService(
settings=SupabaseSettings(public_url="https://test.supabase.co")
)
anon_client = MagicMock()
anon_client.auth.get_session = MagicMock(return_value=None)
@@ -103,7 +113,9 @@ async def test_health_check_initialized(monkeypatch: pytest.MonkeyPatch) -> None
def test_get_client_raises_before_init() -> None:
service = SupabaseService(settings=SupabaseSettings())
service = SupabaseService(
settings=SupabaseSettings(public_url="https://test.supabase.co")
)
with pytest.raises(RuntimeError):
service.get_client()
@@ -13,11 +13,6 @@ def test_social_prefixed_supabase_env_populates_settings(
monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_URL", "https://public.example:8443")
monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key")
monkeypatch.setenv("SOCIAL_SUPABASE__SERVICE_ROLE_KEY", "service-key")
monkeypatch.setenv("SOCIAL_SUPABASE__SITE_URL", "https://app.example.com")
monkeypatch.setenv(
"SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS",
'["https://a.example.com", "https://b.example.com/path"]',
)
monkeypatch.setenv("SOCIAL_DATABASE__HOST", "db")
monkeypatch.setenv("SOCIAL_DATABASE__PORT", "5432")
monkeypatch.setenv("SOCIAL_DATABASE__NAME", "app")
@@ -29,17 +24,11 @@ def test_social_prefixed_supabase_env_populates_settings(
assert str(settings.supabase.public_url) == "https://public.example:8443/"
assert settings.supabase.anon_key == "anon-key"
assert settings.supabase.service_role_key == "service-key"
assert settings.supabase.site_url == "https://app.example.com"
assert settings.supabase.additional_redirect_urls == [
"https://a.example.com",
"https://b.example.com/path",
]
supabase_settings = settings.model_dump()["supabase"]
assert str(supabase_settings["public_url"]) == "https://public.example:8443/"
assert supabase_settings["anon_key"] == "anon-key"
assert supabase_settings["service_role_key"] == "service-key"
assert supabase_settings["site_url"] == "https://app.example.com"
assert "jwt_secret" not in supabase_settings
assert "public_scheme" not in supabase_settings
assert "public_host" not in supabase_settings
+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"