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
@@ -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/"