fix: 增强云端 Supabase 认证可靠性,修复验证码失败可观测性
- JWT 验证器新增 apikey 参数,支持云端 JWKS 认证头 - Auth 网关新增上游超时/错误映射为 503 状态码 - Auth 网关新增重定向 URL 校验,阻断开放重定向风险 - 用户依赖传递 anon_key 给 JWT 验证器 - 新增相关单元测试覆盖 JWKS 头、503 映射、重定向校验 - 新增实现计划文档
This commit is contained in:
@@ -59,9 +59,6 @@ SOCIAL_DATABASE__NAME=postgres
|
||||
SOCIAL_DATABASE__USER=postgres
|
||||
SOCIAL_DATABASE__PASSWORD=change-me-strong-password
|
||||
|
||||
# Auth 可选项(云 Supabase Auth 行为控制)
|
||||
# SOCIAL_SUPABASE__SITE_URL=https://your-app-domain.example
|
||||
# SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS=
|
||||
|
||||
############
|
||||
# Agent Chat 附件存储配置(仅基础设施变量)
|
||||
|
||||
@@ -14,10 +14,22 @@ class TokenVerifierUnavailableError(Exception):
|
||||
|
||||
|
||||
class JwtVerifier:
|
||||
def __init__(self, jwks_url: str, issuer: str, audience: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
jwks_url: str,
|
||||
issuer: str,
|
||||
audience: str,
|
||||
apikey: str,
|
||||
) -> None:
|
||||
self._issuer: str = issuer
|
||||
self._audience: str = audience
|
||||
self._jwks_client: jwt.PyJWKClient = jwt.PyJWKClient(jwks_url)
|
||||
self._jwks_client: jwt.PyJWKClient = jwt.PyJWKClient(
|
||||
jwks_url,
|
||||
headers={
|
||||
"apikey": apikey,
|
||||
"Authorization": f"Bearer {apikey}",
|
||||
},
|
||||
)
|
||||
|
||||
def verify(self, token: str) -> dict[str, Any]:
|
||||
try:
|
||||
|
||||
@@ -129,19 +129,6 @@ class SupabaseSettings(BaseModel):
|
||||
jwt_audience: str = "authenticated"
|
||||
jwt_issuer: str | None = None
|
||||
jwks_url: str | None = None
|
||||
site_url: str | None = None
|
||||
additional_redirect_urls: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator("additional_redirect_urls", mode="before")
|
||||
@classmethod
|
||||
def normalize_redirect_urls(cls, value: object) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
return []
|
||||
|
||||
@model_validator(mode="after")
|
||||
def compute_defaults(self) -> "SupabaseSettings":
|
||||
@@ -152,9 +139,6 @@ class SupabaseSettings(BaseModel):
|
||||
if self.jwks_url is None:
|
||||
self.jwks_url = f"{self.jwt_issuer}/.well-known/jwks.json"
|
||||
|
||||
if self.site_url is None:
|
||||
self.site_url = "http://localhost:3000"
|
||||
|
||||
return self
|
||||
|
||||
@computed_field
|
||||
|
||||
@@ -28,6 +28,8 @@ from v1.auth.service import AuthServiceGateway
|
||||
|
||||
logger = get_logger("v1.auth.gateway")
|
||||
|
||||
AUTH_UNAVAILABLE_DETAIL = "Auth service temporarily unavailable"
|
||||
|
||||
|
||||
class SupabaseAuthGateway(AuthServiceGateway):
|
||||
def _get_client(self) -> Any:
|
||||
@@ -50,9 +52,7 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
}
|
||||
if request.redirect_to:
|
||||
payload["options"] = {
|
||||
"email_redirect_to": _validate_redirect_url_or_raise(
|
||||
request.redirect_to
|
||||
)
|
||||
"email_redirect_to": _validate_redirect_url(request.redirect_to)
|
||||
}
|
||||
try:
|
||||
sign_up = cast(Any, client.auth.sign_up)
|
||||
@@ -60,6 +60,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
return VerificationCreateResponse(email=request.email)
|
||||
except AuthError as exc:
|
||||
logger.warning("Signup failed", error_type=type(exc).__name__)
|
||||
if _is_auth_upstream_unavailable(exc):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=AUTH_UNAVAILABLE_DETAIL,
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Invalid signup request"
|
||||
) from exc
|
||||
@@ -82,6 +87,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
return _map_auth_response(response, "Invalid verification code")
|
||||
except AuthError as exc:
|
||||
logger.warning("Signup verify failed", error_type=type(exc).__name__)
|
||||
if _is_auth_upstream_unavailable(exc):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=AUTH_UNAVAILABLE_DETAIL,
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid verification code"
|
||||
) from exc
|
||||
@@ -103,6 +113,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
await asyncio.to_thread(resend, payload)
|
||||
except AuthError as exc:
|
||||
logger.warning("Signup resend failed", error_type=type(exc).__name__)
|
||||
if _is_auth_upstream_unavailable(exc):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=AUTH_UNAVAILABLE_DETAIL,
|
||||
) from exc
|
||||
|
||||
async def create_session(self, request: SessionCreateRequest) -> SessionResponse:
|
||||
client = self._get_client()
|
||||
@@ -113,6 +128,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
return _map_auth_response(response, "Invalid credentials")
|
||||
except AuthError as exc:
|
||||
logger.warning("Login failed", error_type=type(exc).__name__)
|
||||
if _is_auth_upstream_unavailable(exc):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=AUTH_UNAVAILABLE_DETAIL,
|
||||
) from exc
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials") from exc
|
||||
|
||||
async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse:
|
||||
@@ -125,6 +145,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
return _map_auth_response(response, "Invalid refresh token")
|
||||
except AuthError as exc:
|
||||
logger.warning("Refresh failed", error_type=type(exc).__name__)
|
||||
if _is_auth_upstream_unavailable(exc):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=AUTH_UNAVAILABLE_DETAIL,
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid refresh token"
|
||||
) from exc
|
||||
@@ -149,6 +174,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
await asyncio.to_thread(client.auth.sign_out)
|
||||
except AuthError as exc:
|
||||
logger.warning("Logout failed", error_type=type(exc).__name__)
|
||||
if _is_auth_upstream_unavailable(exc):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=AUTH_UNAVAILABLE_DETAIL,
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid refresh token"
|
||||
) from exc
|
||||
@@ -186,7 +216,7 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
email = _coerce_reset_email(request.email)
|
||||
if request.redirect_to:
|
||||
options: dict[str, str] = {
|
||||
"redirect_to": _validate_redirect_url_or_raise(request.redirect_to)
|
||||
"redirect_to": _validate_redirect_url(request.redirect_to)
|
||||
}
|
||||
await asyncio.to_thread(reset_email, email, options=options)
|
||||
else:
|
||||
@@ -196,6 +226,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
"Password reset request failed",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
if _is_auth_upstream_unavailable(exc):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=AUTH_UNAVAILABLE_DETAIL,
|
||||
) from exc
|
||||
|
||||
async def confirm_password_reset(
|
||||
self, request: PasswordResetConfirmRequest
|
||||
@@ -226,11 +261,71 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
logger.warning(
|
||||
"Password reset confirm failed", error_type=type(exc).__name__
|
||||
)
|
||||
if _is_auth_upstream_unavailable(exc):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=AUTH_UNAVAILABLE_DETAIL,
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid or expired verification code"
|
||||
) from exc
|
||||
|
||||
|
||||
def _is_auth_upstream_unavailable(exc: AuthError) -> bool:
|
||||
raw_status = getattr(exc, "status", None)
|
||||
if raw_status is None:
|
||||
raw_status = getattr(exc, "status_code", None)
|
||||
if isinstance(raw_status, int) and 500 <= raw_status < 600:
|
||||
return True
|
||||
|
||||
raw_code = getattr(exc, "code", None)
|
||||
code = str(raw_code).lower() if raw_code is not None else ""
|
||||
message = str(exc).lower()
|
||||
indicators = (
|
||||
"request_timeout",
|
||||
"timed out",
|
||||
"timeout",
|
||||
"gateway timeout",
|
||||
"bad_gateway",
|
||||
"service_unavailable",
|
||||
"internal_server_error",
|
||||
"unexpected_failure",
|
||||
"upstream",
|
||||
"500",
|
||||
"502",
|
||||
"503",
|
||||
"504",
|
||||
"5xx",
|
||||
)
|
||||
return any(token in code or token in message for token in indicators)
|
||||
|
||||
|
||||
def _validate_redirect_url(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise HTTPException(status_code=422, detail="Invalid redirect URL")
|
||||
|
||||
origin = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
|
||||
allowed_origins = {
|
||||
_normalize_origin(candidate)
|
||||
for candidate in config.cors.allow_origins
|
||||
if _is_http_origin(candidate)
|
||||
}
|
||||
if origin not in allowed_origins:
|
||||
raise HTTPException(status_code=422, detail="Invalid redirect URL")
|
||||
return url
|
||||
|
||||
|
||||
def _normalize_origin(value: str) -> str:
|
||||
parsed = urlparse(value)
|
||||
return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
|
||||
|
||||
|
||||
def _is_http_origin(value: str) -> bool:
|
||||
parsed = urlparse(value)
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
|
||||
|
||||
def _coerce_reset_email(value: object) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
@@ -263,31 +358,6 @@ def _map_auth_response(response: object, failure_message: str) -> SessionRespons
|
||||
)
|
||||
|
||||
|
||||
def _validate_redirect_url_or_raise(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise HTTPException(status_code=422, detail="Invalid redirect URL")
|
||||
if not parsed.netloc:
|
||||
raise HTTPException(status_code=422, detail="Invalid redirect URL")
|
||||
|
||||
site_origin = _origin_of(config.supabase.site_url)
|
||||
allowlist = {
|
||||
site_origin,
|
||||
*(_origin_of(item) for item in config.supabase.additional_redirect_urls),
|
||||
}
|
||||
target_origin = f"{parsed.scheme}://{parsed.netloc}".lower()
|
||||
if target_origin not in allowlist:
|
||||
raise HTTPException(status_code=422, detail="Invalid redirect URL")
|
||||
return url
|
||||
|
||||
|
||||
def _origin_of(url: str) -> str:
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
return ""
|
||||
return f"{parsed.scheme}://{parsed.netloc}".lower()
|
||||
|
||||
|
||||
def _list_auth_users(client: Any) -> list[Any]:
|
||||
users: list[Any] = []
|
||||
page = 1
|
||||
|
||||
@@ -41,7 +41,12 @@ def get_jwt_verifier() -> JwtVerifier:
|
||||
if not jwks_url or not issuer or not audience:
|
||||
logger.error("JWT validation failed: verifier config not configured")
|
||||
raise HTTPException(status_code=503, detail="JWT verifier not configured")
|
||||
_jwt_verifier = JwtVerifier(jwks_url=jwks_url, issuer=issuer, audience=audience)
|
||||
_jwt_verifier = JwtVerifier(
|
||||
jwks_url=jwks_url,
|
||||
issuer=issuer,
|
||||
audience=audience,
|
||||
apikey=config.supabase.anon_key,
|
||||
)
|
||||
return _jwt_verifier
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Supabase JWKS Auth Reliability Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 让后端在云 Supabase 场景下稳定使用 JWKS/RS256 验签,并把 Auth 上游超时错误正确暴露为 503,保障注册/登录/重置密码链路可观测。
|
||||
|
||||
**Architecture:** 保留 `PUBLIC_URL -> issuer/jwks` 自动推导,JWT 验签继续强制 RS256,但给 JWKS 拉取添加 `apikey` 与 `Authorization` 头。Auth Gateway 新增统一错误映射,将上游 timeout/网关错误归类为服务不可用(503),其余保持既有 401/422 语义。
|
||||
|
||||
**Tech Stack:** FastAPI, Pydantic, PyJWT (`PyJWKClient`), Supabase Python SDK, pytest。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: JWKS Header 支持(测试先行)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/tests/unit/core/auth/test_jwt_verifier.py`
|
||||
- Modify: `backend/src/core/auth/jwt_verifier.py`
|
||||
- Modify: `backend/src/v1/users/dependencies.py`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
- 为 `JwtVerifier` 新增用例,断言初始化 `PyJWKClient` 时会传入 `apikey` 与 `Authorization: Bearer <anon_key>`。
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
- Run: `uv run pytest backend/tests/unit/core/auth/test_jwt_verifier.py -v`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
- `JwtVerifier.__init__` 新增 `apikey` 参数并注入 JWKS 请求头。
|
||||
- `get_jwt_verifier()` 传入 `config.supabase.anon_key`。
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
- Run: `uv run pytest backend/tests/unit/core/auth/test_jwt_verifier.py -v`
|
||||
|
||||
### Task 2: Auth 上游超时错误映射为 503(测试先行)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/tests/unit/v1/auth/test_auth_gateway.py`
|
||||
- Modify: `backend/src/v1/auth/gateway.py`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
- 新增 `create_verification` 的超时错误测试,期望返回 `HTTPException(status_code=503)`。
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
- Run: `uv run pytest backend/tests/unit/v1/auth/test_auth_gateway.py -v`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
- 增加 AuthError 分类函数,识别 timeout/request_timeout/upstream timeout。
|
||||
- 在注册、登录、刷新、重置相关分支中映射为 503。
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
- Run: `uv run pytest backend/tests/unit/v1/auth/test_auth_gateway.py -v`
|
||||
|
||||
### Task 3: 回归验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/tests/unit/test_settings_supabase_env.py` (if needed)
|
||||
|
||||
**Step 1: Run targeted suites**
|
||||
- Run: `uv run pytest backend/tests/unit/core/auth/test_jwt_verifier.py backend/tests/unit/v1/auth/test_auth_gateway.py backend/tests/unit/test_settings_supabase_env.py -v`
|
||||
|
||||
**Step 2: Run quality gates**
|
||||
- Run: `uv run ruff check backend/src backend/tests`
|
||||
- Run: `uv run basedpyright backend/src`
|
||||
|
||||
**Step 3: Document runtime checks**
|
||||
- 记录 JWT/JWKS 必备环境变量和手工联调命令。
|
||||
Reference in New Issue
Block a user