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
@@ -1,45 +1,43 @@
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
import httpx
import jwt
import pytest
from sqlalchemy import select
from core.config import config
from core.db.session import AsyncSessionLocal
from models.agent_chat_message import AgentChatMessage
from models.agent_chat_session import AgentChatSession
from models.profile import Profile
BASE_URL = os.getenv("AGENT_LIVE_BASE_URL", "http://localhost:5775")
async def _owner_id() -> UUID:
async with AsyncSessionLocal() as session:
owner_id = (await session.execute(select(Profile.id).limit(1))).scalar_one_or_none()
if owner_id is None:
pytest.skip("profile owner not found")
return owner_id
async def _live_access_token(client: httpx.AsyncClient) -> str:
email = os.getenv("AGENT_LIVE_EMAIL")
password = os.getenv("AGENT_LIVE_PASSWORD")
if not email or not password:
pytest.fail(
"AGENT_LIVE_INTEGRATION=1 requires AGENT_LIVE_EMAIL and AGENT_LIVE_PASSWORD"
)
response = await client.post(
f"{BASE_URL}/api/v1/auth/sessions",
json={"email": email, "password": password},
)
response_text = response.text.strip().replace("\n", " ")
truncated_text = response_text[:200]
if len(response_text) > 200:
truncated_text += "..."
def _jwt_for(user_id: UUID) -> str:
secret = config.supabase.jwt_secret
if not secret:
pytest.skip("JWT secret not configured")
issuer = f"{config.supabase.public_url.rstrip('/')}/auth/v1"
payload = {
"sub": str(user_id),
"role": "authenticated",
"aud": "authenticated",
"iss": issuer,
"iat": datetime.now(timezone.utc),
"exp": datetime.now(timezone.utc) + timedelta(minutes=30),
}
return jwt.encode(payload, secret, algorithm="HS256")
assert response.status_code == 200, (
f"live login failed: status={response.status_code}, response={truncated_text!r}"
)
token = response.json().get("access_token")
assert isinstance(token, str) and token
return token
@pytest.mark.asyncio
@@ -48,11 +46,10 @@ async def test_agent_sse_closed_loop_live() -> None:
if os.getenv("AGENT_LIVE_INTEGRATION") != "1":
pytest.skip("set AGENT_LIVE_INTEGRATION=1 to run live integration test")
owner_id = await _owner_id()
token = _jwt_for(owner_id)
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient(timeout=30.0) as client:
token = await _live_access_token(client)
headers = {"Authorization": f"Bearer {token}"}
run_resp = await client.post(
f"{BASE_URL}/api/v1/agent/runs",
headers=headers,
@@ -76,9 +73,13 @@ async def test_agent_sse_closed_loop_live() -> None:
events_url = f"{BASE_URL}/api/v1/agent/runs/{thread_id}/events"
event_names: list[str] = []
async with client.stream("GET", events_url, headers=headers, timeout=20.0) as sse_resp:
async with client.stream(
"GET", events_url, headers=headers, timeout=20.0
) as sse_resp:
assert sse_resp.status_code == 200
assert sse_resp.headers.get("content-type", "").startswith("text/event-stream")
assert sse_resp.headers.get("content-type", "").startswith(
"text/event-stream"
)
async for line in sse_resp.aiter_lines():
if line.startswith("event:"):
event_names.append(line.split(":", 1)[1].strip())
@@ -94,6 +95,8 @@ async def test_agent_sse_closed_loop_live() -> None:
assert session_row.total_cost >= 0
rows = await session.execute(
select(AgentChatMessage).where(AgentChatMessage.session_id == UUID(thread_id))
select(AgentChatMessage).where(
AgentChatMessage.session_id == UUID(thread_id)
)
)
assert len(list(rows.scalars().all())) >= 1
@@ -0,0 +1,268 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any, cast
from uuid import uuid4
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from core.auth.jwt_verifier import (
JwtVerifier,
TokenValidationError,
TokenVerifierUnavailableError,
)
def _set_jwks_client(verifier: JwtVerifier, client: Any) -> None:
cast(Any, verifier)._jwks_client = client
def _build_rsa_key_pair() -> tuple[str, str]:
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
public_pem = (
private_key.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode("utf-8")
)
return private_pem, public_pem
def _build_token(*, private_key: str, sub: str, audience: str, issuer: str) -> str:
now = datetime.now(UTC)
payload = {
"sub": sub,
"aud": audience,
"iss": issuer,
"exp": now + timedelta(minutes=5),
}
return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": "kid-1"})
def _build_expired_token(
*, private_key: str, sub: str, audience: str, issuer: str
) -> str:
now = datetime.now(UTC)
payload = {
"sub": sub,
"aud": audience,
"iss": issuer,
"exp": now - timedelta(minutes=1),
}
return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": "kid-1"})
def _build_hs256_token(*, secret: str, sub: str, audience: str, issuer: str) -> str:
now = datetime.now(UTC)
payload = {
"sub": sub,
"aud": audience,
"iss": issuer,
"exp": now + timedelta(minutes=5),
}
return jwt.encode(payload, secret, algorithm="HS256", headers={"kid": "kid-1"})
def test_verify_token_with_jwks_success() -> None:
user_id = uuid4()
audience = "authenticated"
issuer = "https://example.supabase.co/auth/v1"
private_key, public_key = _build_rsa_key_pair()
token = _build_token(
private_key=private_key,
sub=str(user_id),
audience=audience,
issuer=issuer,
)
verifier = JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
)
_set_jwks_client(
verifier,
SimpleNamespace(
get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key)
),
)
claims = verifier.verify(token)
assert claims["sub"] == str(user_id)
def test_verify_token_rejects_invalid_issuer() -> None:
audience = "authenticated"
issuer = "https://example.supabase.co/auth/v1"
private_key, public_key = _build_rsa_key_pair()
token_with_wrong_iss = _build_token(
private_key=private_key,
sub=str(uuid4()),
audience=audience,
issuer="https://wrong-issuer.example.com/auth/v1",
)
verifier = JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
)
_set_jwks_client(
verifier,
SimpleNamespace(
get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key)
),
)
with pytest.raises(TokenValidationError):
verifier.verify(token_with_wrong_iss)
def test_verify_token_rejects_hs256_token() -> None:
audience = "authenticated"
issuer = "https://example.supabase.co/auth/v1"
_, public_key = _build_rsa_key_pair()
hs_token = _build_hs256_token(
secret="test-secret",
sub=str(uuid4()),
audience=audience,
issuer=issuer,
)
verifier = JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
)
_set_jwks_client(
verifier,
SimpleNamespace(
get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key)
),
)
with pytest.raises(TokenValidationError):
verifier.verify(hs_token)
def test_verify_token_rejects_expired_token() -> None:
audience = "authenticated"
issuer = "https://example.supabase.co/auth/v1"
private_key, public_key = _build_rsa_key_pair()
expired_token = _build_expired_token(
private_key=private_key,
sub=str(uuid4()),
audience=audience,
issuer=issuer,
)
verifier = JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
)
_set_jwks_client(
verifier,
SimpleNamespace(
get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key)
),
)
with pytest.raises(TokenValidationError):
verifier.verify(expired_token)
def test_verify_token_rejects_invalid_audience() -> None:
audience = "authenticated"
issuer = "https://example.supabase.co/auth/v1"
private_key, public_key = _build_rsa_key_pair()
wrong_aud_token = _build_token(
private_key=private_key,
sub=str(uuid4()),
audience="anon",
issuer=issuer,
)
verifier = JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
)
_set_jwks_client(
verifier,
SimpleNamespace(
get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key)
),
)
with pytest.raises(TokenValidationError):
verifier.verify(wrong_aud_token)
def test_verify_token_rejects_invalid_signature() -> None:
audience = "authenticated"
issuer = "https://example.supabase.co/auth/v1"
private_key, public_key = _build_rsa_key_pair()
valid_token = _build_token(
private_key=private_key,
sub=str(uuid4()),
audience=audience,
issuer=issuer,
)
tampered_token = f"{valid_token}x"
verifier = JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
)
_set_jwks_client(
verifier,
SimpleNamespace(
get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key)
),
)
with pytest.raises(TokenValidationError):
verifier.verify(tampered_token)
def test_verify_token_maps_jwks_connection_error() -> None:
audience = "authenticated"
issuer = "https://example.supabase.co/auth/v1"
private_key, _ = _build_rsa_key_pair()
token = _build_token(
private_key=private_key,
sub=str(uuid4()),
audience=audience,
issuer=issuer,
)
verifier = JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
)
def _raise_connection_error(_: str) -> SimpleNamespace:
raise jwt.PyJWKClientConnectionError("network down")
_set_jwks_client(
verifier,
SimpleNamespace(get_signing_key_from_jwt=_raise_connection_error),
)
with pytest.raises(TokenVerifierUnavailableError):
verifier.verify(token)
@@ -1,19 +1,18 @@
from __future__ import annotations
import pytest
from pydantic import ValidationError
from pytest import MonkeyPatch
from core.config.settings import Settings
from core.config.settings import Settings, SupabaseSettings
def test_social_prefixed_supabase_env_populates_settings(
monkeypatch: MonkeyPatch,
) -> None:
monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_SCHEME", "https")
monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_HOST", "public.example")
monkeypatch.setenv("SOCIAL_SUPABASE__KONG_HTTP_PORT", "8443")
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__JWT_SECRET", "jwt-secret")
monkeypatch.setenv("SOCIAL_SUPABASE__SITE_URL", "https://app.example.com")
monkeypatch.setenv(
"SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS",
@@ -27,10 +26,9 @@ def test_social_prefixed_supabase_env_populates_settings(
settings = Settings()
assert settings.supabase.public_url == "https://public.example:8443"
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.jwt_secret == "jwt-secret"
assert settings.supabase.site_url == "https://app.example.com"
assert settings.supabase.additional_redirect_urls == [
"https://a.example.com",
@@ -38,9 +36,63 @@ def test_social_prefixed_supabase_env_populates_settings(
]
supabase_settings = settings.model_dump()["supabase"]
assert supabase_settings["public_url"] == "https://public.example:8443"
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["jwt_secret"] == "jwt-secret"
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
assert "kong_http_port" not in supabase_settings
assert settings.database_url == "postgresql+asyncpg://user:pass@db:5432/app"
def test_cloud_supabase_env_populates_settings(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setenv(
"SOCIAL_SUPABASE__PUBLIC_URL", "https://project.example.supabase.co"
)
monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key")
monkeypatch.setenv("SOCIAL_SUPABASE__SERVICE_ROLE_KEY", "service-key")
monkeypatch.setenv("SOCIAL_SUPABASE__JWT_AUDIENCE", "authenticated")
settings = Settings()
assert str(settings.supabase.public_url) == "https://project.example.supabase.co/"
assert settings.supabase.jwt_audience == "authenticated"
assert settings.supabase.jwt_issuer == "https://project.example.supabase.co/auth/v1"
assert (
settings.supabase.jwks_url
== "https://project.example.supabase.co/auth/v1/.well-known/jwks.json"
)
supabase_settings = settings.model_dump()["supabase"]
assert "jwt_secret" not in supabase_settings
def test_missing_public_url_raises_validation_error() -> None:
with pytest.raises(ValidationError) as exc_info:
SupabaseSettings()
assert "public_url" in str(exc_info.value)
def test_public_url_with_trailing_slash_normalizes_correctly(
monkeypatch: MonkeyPatch,
) -> None:
monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_URL", "https://example.supabase.co/")
monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key")
monkeypatch.setenv("SOCIAL_SUPABASE__SERVICE_ROLE_KEY", "service-key")
monkeypatch.setenv("SOCIAL_DATABASE__HOST", "db")
monkeypatch.setenv("SOCIAL_DATABASE__PORT", "5432")
monkeypatch.setenv("SOCIAL_DATABASE__NAME", "app")
monkeypatch.setenv("SOCIAL_DATABASE__USER", "user")
monkeypatch.setenv("SOCIAL_DATABASE__PASSWORD", "pass")
settings = Settings()
assert settings.supabase.jwt_issuer == "https://example.supabase.co/auth/v1"
assert (
settings.supabase.jwks_url
== "https://example.supabase.co/auth/v1/.well-known/jwks.json"
)
assert settings.supabase.url == "https://example.supabase.co/"