diff --git a/.env.example b/.env.example index dbad8ba..fe3e1b5 100644 --- a/.env.example +++ b/.env.example @@ -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 附件存储配置(仅基础设施变量) diff --git a/backend/src/core/auth/jwt_verifier.py b/backend/src/core/auth/jwt_verifier.py index d232cf8..d2b9be7 100644 --- a/backend/src/core/auth/jwt_verifier.py +++ b/backend/src/core/auth/jwt_verifier.py @@ -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: diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 94976df..c395aa7 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -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 diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py index 065b125..51a2a44 100644 --- a/backend/src/v1/auth/gateway.py +++ b/backend/src/v1/auth/gateway.py @@ -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 diff --git a/backend/src/v1/users/dependencies.py b/backend/src/v1/users/dependencies.py index b17c44c..ae20b1b 100644 --- a/backend/src/v1/users/dependencies.py +++ b/backend/src/v1/users/dependencies.py @@ -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 diff --git a/backend/tests/unit/core/auth/test_jwt_verifier.py b/backend/tests/unit/core/auth/test_jwt_verifier.py index dffc2e9..015ef3e 100644 --- a/backend/tests/unit/core/auth/test_jwt_verifier.py +++ b/backend/tests/unit/core/auth/test_jwt_verifier.py @@ -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: diff --git a/backend/tests/unit/services/base/test_supabase_service.py b/backend/tests/unit/services/base/test_supabase_service.py index d0db5d2..8edb136 100644 --- a/backend/tests/unit/services/base/test_supabase_service.py +++ b/backend/tests/unit/services/base/test_supabase_service.py @@ -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() diff --git a/backend/tests/unit/test_settings_supabase_env.py b/backend/tests/unit/test_settings_supabase_env.py index b4b4063..9ecd738 100644 --- a/backend/tests/unit/test_settings_supabase_env.py +++ b/backend/tests/unit/test_settings_supabase_env.py @@ -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 diff --git a/backend/tests/unit/v1/auth/test_auth_gateway.py b/backend/tests/unit/v1/auth/test_auth_gateway.py index a66ae97..bf4c350 100644 --- a/backend/tests/unit/v1/auth/test_auth_gateway.py +++ b/backend/tests/unit/v1/auth/test_auth_gateway.py @@ -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" diff --git a/docs/plans/2026-03-10-supabase-jwks-auth-reliability-plan.md b/docs/plans/2026-03-10-supabase-jwks-auth-reliability-plan.md new file mode 100644 index 0000000..dd963ff --- /dev/null +++ b/docs/plans/2026-03-10-supabase-jwks-auth-reliability-plan.md @@ -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 `。 + +**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 必备环境变量和手工联调命令。