refactor: 迁移本地 Supabase 到云端,使用 JWKS 进行 JWT 验证

- 新增 JwtVerifier 支持 RS256 + JWKS 验证
- 简化 docker-compose,删除本地 Supabase 服务(kong/auth/storage等)
- 删除冗余的 Supabase 配置文件(volumes目录)
- 适配测试用例以支持新配置方式
- 更新运行时文档和迁移计划
This commit is contained in:
qzl
2026-03-09 18:03:04 +08:00
parent 3ac09475ad
commit 6fe2e7b6c3
24 changed files with 825 additions and 1403 deletions
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from typing import Any, cast
import jwt
class TokenValidationError(Exception):
pass
class TokenVerifierUnavailableError(Exception):
pass
class JwtVerifier:
def __init__(self, jwks_url: str, issuer: str, audience: str) -> None:
self._issuer: str = issuer
self._audience: str = audience
self._jwks_client: jwt.PyJWKClient = jwt.PyJWKClient(jwks_url)
def verify(self, token: str) -> dict[str, Any]:
try:
key = self._jwks_client.get_signing_key_from_jwt(token)
except jwt.PyJWKClientConnectionError as exc:
raise TokenVerifierUnavailableError("Unable to fetch JWKS") from exc
except jwt.PyJWKClientError as exc:
raise TokenValidationError("Unable to resolve signing key") from exc
try:
payload = jwt.decode(
token,
key.key,
algorithms=["RS256"],
audience=self._audience,
issuer=self._issuer,
options={"require": ["sub", "aud", "iss", "exp"]},
)
except (
jwt.ExpiredSignatureError,
jwt.InvalidAudienceError,
jwt.InvalidIssuerError,
jwt.InvalidSignatureError,
jwt.DecodeError,
jwt.PyJWTError,
) as exc:
raise TokenValidationError("Token validation failed") from exc
if not isinstance(payload, dict):
raise TokenValidationError("Token payload must be a JSON object")
return cast(dict[str, Any], payload)
+30 -14
View File
@@ -4,7 +4,14 @@ from pathlib import Path
from typing import ClassVar, Literal
from urllib.parse import quote
from pydantic import BaseModel, Field, computed_field, field_validator, model_validator
from pydantic import (
AnyHttpUrl,
BaseModel,
Field,
computed_field,
field_validator,
model_validator,
)
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -116,14 +123,14 @@ class RedisSettings(BaseModel):
class SupabaseSettings(BaseModel):
public_scheme: str = "http"
public_host: str = "localhost"
kong_http_port: int = 8000
site_url: str = "http://localhost:3000"
additional_redirect_urls: list[str] = Field(default_factory=list)
public_url: AnyHttpUrl
anon_key: str = "CHANGE_ME"
service_role_key: str = "CHANGE_ME"
jwt_secret: str | None = None
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
@@ -136,15 +143,24 @@ class SupabaseSettings(BaseModel):
return [str(item).strip() for item in value if str(item).strip()]
return []
@computed_field
@property
def public_url(self) -> str:
return f"{self.public_scheme}://{self.public_host}:{self.kong_http_port}"
@model_validator(mode="after")
def compute_defaults(self) -> "SupabaseSettings":
base = str(self.public_url).rstrip("/")
if self.jwt_issuer is None:
self.jwt_issuer = f"{base}/auth/v1"
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
@property
def url(self) -> str:
return self.public_url
return str(self.public_url)
class StorageSettings(BaseModel):
@@ -205,7 +221,7 @@ class Settings(BaseSettings):
runtime: RuntimeSettings = RuntimeSettings()
cors: CorsSettings = CorsSettings()
redis: RedisSettings = RedisSettings()
supabase: SupabaseSettings = SupabaseSettings()
supabase: SupabaseSettings = Field()
storage: StorageSettings = StorageSettings()
llm: LlmSettings = LlmSettings()
agent_runtime: AgentRuntimeSettings = AgentRuntimeSettings()
@@ -236,4 +252,4 @@ class Settings(BaseSettings):
)
config = Settings()
config = Settings() # type: ignore[reportCallIssue]
+28 -39
View File
@@ -3,10 +3,14 @@ from __future__ import annotations
from typing import Annotated
from uuid import UUID
import jwt
from fastapi import Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.jwt_verifier import (
JwtVerifier,
TokenValidationError,
TokenVerifierUnavailableError,
)
from core.auth.models import CurrentUser
from core.config.settings import config
from core.db import get_db
@@ -18,6 +22,7 @@ from v1.users.service import AuthLookupAdapter, UserService
logger = get_logger("v1.users.dependencies")
_auth_gateway: SupabaseAuthGateway | None = None
_jwt_verifier: JwtVerifier | None = None
def get_auth_gateway() -> SupabaseAuthGateway:
@@ -27,6 +32,19 @@ def get_auth_gateway() -> SupabaseAuthGateway:
return _auth_gateway
def get_jwt_verifier() -> JwtVerifier:
global _jwt_verifier
if _jwt_verifier is None:
jwks_url = config.supabase.jwks_url
issuer = config.supabase.jwt_issuer
audience = config.supabase.jwt_audience
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)
return _jwt_verifier
def get_current_user(authorization: str | None = Header(default=None)) -> CurrentUser:
if not authorization:
logger.warning("JWT validation failed: missing authorization header")
@@ -37,46 +55,17 @@ def get_current_user(authorization: str | None = Header(default=None)) -> Curren
logger.warning("JWT validation failed: invalid authorization scheme")
raise HTTPException(status_code=401, detail="Unauthorized")
secret = config.supabase.jwt_secret
if not secret:
logger.error("JWT validation failed: secret not configured")
raise HTTPException(status_code=503, detail="JWT secret not configured")
supabase_url = config.supabase.public_url.rstrip("/")
expected_issuer = f"{supabase_url}/auth/v1"
try:
payload = jwt.decode(
token,
secret,
algorithms=["HS256"],
audience="authenticated",
issuer=expected_issuer,
options={
"verify_aud": True,
"verify_iss": True,
"verify_exp": True,
"require": ["sub", "aud", "iss", "exp"],
},
)
except jwt.ExpiredSignatureError:
logger.warning("JWT validation failed: token expired")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.InvalidAudienceError:
logger.warning("JWT validation failed: invalid audience")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.InvalidIssuerError:
logger.warning("JWT validation failed: invalid issuer")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.InvalidSignatureError:
logger.warning("JWT validation failed: invalid signature")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.DecodeError:
logger.warning("JWT validation failed: malformed token")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.PyJWTError as exc:
payload = get_jwt_verifier().verify(token)
except HTTPException:
raise
except TokenVerifierUnavailableError:
logger.error("JWT validation failed: verifier unavailable")
raise HTTPException(status_code=503, detail="JWT verifier unavailable")
except TokenValidationError as exc:
logger.warning(
"JWT validation failed: unknown error", error_type=type(exc).__name__
"JWT validation failed",
error_type=type(exc).__name__,
)
raise HTTPException(status_code=401, detail="Unauthorized") from exc