Spaces:
Running
Running
| """Hosted Scalekit, Neon credential, and Spotify OAuth behavior.""" | |
| from __future__ import annotations | |
| import base64 | |
| import json | |
| from types import SimpleNamespace | |
| from typing import Any | |
| from urllib.parse import parse_qs, urlparse | |
| import httpx2 | |
| import jwt | |
| import pytest | |
| from cryptography.fernet import Fernet | |
| from cryptography.hazmat.primitives.asymmetric import rsa | |
| from mcp.server.auth.provider import AccessToken | |
| from spotify_mcp_server.server import create_server | |
| from spotify_mcp_server.server_auth import ( | |
| HostedSettings, | |
| ScalekitTokenVerifier, | |
| _ScalekitJWTClaimsClient, | |
| _ThrottledJWKSClient, | |
| _ValidationOptions, | |
| ) | |
| from spotify_mcp_server.spotify.auth import AuthenticationError | |
| from spotify_mcp_server.spotify.config import SCOPES, Settings | |
| from spotify_mcp_server.spotify.hosted import ( | |
| EncryptedRefreshTokenStore, | |
| HostedSpotifyServices, | |
| SecretCipher, | |
| SpotifyConnectionRequired, | |
| SpotifyUserNotAllowed, | |
| _asyncpg_dsn, | |
| ) | |
| pytestmark = pytest.mark.anyio | |
| class MemoryRepository: | |
| def __init__(self) -> None: | |
| self.values: dict[str, bytes] = {} | |
| self.initialized = False | |
| async def initialize(self) -> None: | |
| self.initialized = True | |
| async def close(self) -> None: | |
| self.initialized = False | |
| async def load(self, subject: str) -> bytes | None: | |
| return self.values.get(subject) | |
| async def save(self, subject: str, ciphertext: bytes) -> None: | |
| self.values[subject] = ciphertext | |
| def hosted_settings(*, allowed_subjects: frozenset[str] = frozenset()) -> HostedSettings: | |
| return HostedSettings( | |
| scalekit_environment_url="https://tenant.scalekit.dev", | |
| scalekit_resource_id="res_123", | |
| mcp_server_url="https://spotify.example/mcp", | |
| database_url="postgresql://user:pass@db.example/spotify?sslmode=require", | |
| token_encryption_key=Fernet.generate_key().decode("ascii"), | |
| spotify=Settings( | |
| client_id="spotify-client", | |
| redirect_uri="https://spotify.example/spotify/callback", | |
| accounts_base_url="https://accounts.spotify.test", | |
| ), | |
| allowed_subjects=allowed_subjects, | |
| ) | |
| def test_hosted_settings_require_complete_https_configuration( | |
| monkeypatch: pytest.MonkeyPatch, | |
| ) -> None: | |
| values = { | |
| "SCALEKIT_ENVIRONMENT_URL": "https://tenant.scalekit.dev", | |
| "SCALEKIT_RESOURCE_ID": "res_123", | |
| "MCP_SERVER_URL": "https://spotify.example/mcp", | |
| "DATABASE_URL": "postgresql://database", | |
| "TOKEN_ENCRYPTION_KEY": Fernet.generate_key().decode("ascii"), | |
| "SPOTIFY_CLIENT_ID": "spotify-client", | |
| } | |
| for name, value in values.items(): | |
| monkeypatch.setenv(name, value) | |
| resolved = HostedSettings.from_env() | |
| assert resolved.spotify.redirect_uri == "https://spotify.example/spotify/callback" | |
| assert resolved.authorization_server_url == "https://tenant.scalekit.dev/resources/res_123" | |
| monkeypatch.setenv("MCP_SERVER_URL", "http://spotify.example/mcp") | |
| with pytest.raises(ValueError, match="HTTPS"): | |
| HostedSettings.from_env() | |
| def test_scalekit_jwt_client_validates_signature_issuer_and_audience() -> None: | |
| private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) | |
| token = jwt.encode( | |
| { | |
| "sub": "usr_123", | |
| "iss": "https://tenant.scalekit.dev", | |
| "aud": "https://spotify.example/mcp", | |
| "exp": 2_000_000_000, | |
| }, | |
| private_key, | |
| algorithm="RS256", | |
| headers={"kid": "key-1"}, | |
| ) | |
| class SigningKeyClient: | |
| def get_signing_key_from_jwt(self, encoded: str) -> SimpleNamespace: | |
| assert encoded == token | |
| return SimpleNamespace(key=private_key.public_key()) | |
| client = _ScalekitJWTClaimsClient( | |
| "https://tenant.scalekit.dev", | |
| jwks_client=SigningKeyClient(), | |
| ) | |
| claims = client.validate_access_token_and_get_claims( | |
| token, | |
| _ValidationOptions( | |
| issuer="https://tenant.scalekit.dev", | |
| audience=["https://spotify.example/mcp"], | |
| ), | |
| ) | |
| assert claims["sub"] == "usr_123" | |
| with pytest.raises(jwt.InvalidAudienceError): | |
| client.validate_access_token_and_get_claims( | |
| token, | |
| _ValidationOptions( | |
| issuer="https://tenant.scalekit.dev", | |
| audience=["https://other.example/mcp"], | |
| ), | |
| ) | |
| class RecordingJWKSClient: | |
| """Stands in for PyJWKClient, counting the fetches a caller actually provokes.""" | |
| def __init__(self, kid: str = "key-1") -> None: | |
| self.key = SimpleNamespace(key_id=kid) | |
| self.fetches = 0 | |
| self.refreshes = 0 | |
| def get_signing_keys(self, refresh: bool = False) -> list[Any]: | |
| self.fetches += 1 | |
| if refresh: | |
| self.refreshes += 1 | |
| return [self.key] | |
| def match_kid(signing_keys: list[Any], kid: str) -> Any | None: | |
| return next((key for key in signing_keys if key.key_id == kid), None) | |
| def _unsigned_token(headers: dict[str, Any]) -> str: | |
| """Build a token for its header alone; key resolution never inspects the signature.""" | |
| def segment(payload: dict[str, Any]) -> str: | |
| raw = json.dumps(payload, separators=(",", ":")).encode() | |
| return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() | |
| return f"{segment(headers)}.{segment({'sub': 'usr_123'})}.not-a-signature" | |
| def test_forged_key_ids_cannot_drive_repeated_jwks_refreshes() -> None: | |
| inner = RecordingJWKSClient() | |
| clock = SimpleNamespace(value=1_000.0) | |
| client = _ThrottledJWKSClient( | |
| "https://tenant.scalekit.dev/keys", | |
| client=inner, | |
| min_refresh_interval=30.0, | |
| now=lambda: clock.value, | |
| ) | |
| for index in range(5): | |
| with pytest.raises(jwt.PyJWKClientError): | |
| client.get_signing_key_from_jwt( | |
| _unsigned_token({"alg": "RS256", "kid": f"forged-{index}"}) | |
| ) | |
| # Five unauthenticated requests, one refresh: the rest are refused by the throttle. | |
| assert inner.refreshes == 1 | |
| # Once the interval elapses a genuine rotation is still picked up. | |
| clock.value += 31.0 | |
| with pytest.raises(jwt.PyJWKClientError): | |
| client.get_signing_key_from_jwt(_unsigned_token({"alg": "RS256", "kid": "forged-late"})) | |
| assert inner.refreshes == 2 | |
| def test_known_key_id_resolves_without_refreshing() -> None: | |
| inner = RecordingJWKSClient() | |
| client = _ThrottledJWKSClient("https://tenant.scalekit.dev/keys", client=inner) | |
| key = client.get_signing_key_from_jwt(_unsigned_token({"alg": "RS256", "kid": "key-1"})) | |
| assert key is inner.key | |
| assert inner.refreshes == 0 | |
| def test_disallowed_algorithm_is_rejected_before_any_key_fetch() -> None: | |
| inner = RecordingJWKSClient() | |
| client = _ThrottledJWKSClient("https://tenant.scalekit.dev/keys", client=inner) | |
| with pytest.raises(jwt.InvalidAlgorithmError): | |
| client.get_signing_key_from_jwt(_unsigned_token({"alg": "HS256", "kid": "key-1"})) | |
| with pytest.raises(jwt.PyJWKClientError): | |
| client.get_signing_key_from_jwt(_unsigned_token({"alg": "RS256"})) | |
| assert inner.fetches == 0 | |
| async def test_scalekit_verifier_validates_audience_and_exposes_subject() -> None: | |
| class ClaimsClient: | |
| def validate_access_token_and_get_claims(self, token: str, options: Any) -> dict[str, Any]: | |
| assert token == "signed" | |
| assert options.issuer == "https://tenant.scalekit.dev" | |
| assert options.audience == ["https://spotify.example/mcp"] | |
| return { | |
| "sub": "usr_123", | |
| "client_id": "https://client.example/metadata.json", | |
| "iss": "https://tenant.scalekit.dev", | |
| "exp": 2_000_000_000, | |
| "scopes": ["spotify:use"], | |
| } | |
| verifier = ScalekitTokenVerifier(hosted_settings(), client=ClaimsClient()) | |
| verified = await verifier.verify_token("signed") | |
| assert isinstance(verified, AccessToken) | |
| assert verified.subject == "usr_123" | |
| assert verified.client_id == "https://client.example/metadata.json" | |
| assert verified.scopes == ["spotify:use"] | |
| async def test_scalekit_verifier_rejects_invalid_tokens_without_exposing_details() -> None: | |
| class RejectingClient: | |
| def validate_access_token_and_get_claims(self, token: str, options: Any) -> dict[str, Any]: | |
| raise RuntimeError(f"sensitive failure for {token} and {options}") | |
| verifier = ScalekitTokenVerifier(hosted_settings(), client=RejectingClient()) | |
| assert await verifier.verify_token("do-not-log") is None | |
| async def test_refresh_token_is_encrypted_before_repository_storage() -> None: | |
| repository = MemoryRepository() | |
| cipher = SecretCipher(Fernet.generate_key().decode("ascii")) | |
| store = EncryptedRefreshTokenStore(repository, cipher, "usr_123") | |
| await store.save("spotify-refresh-token") | |
| assert b"spotify-refresh-token" not in repository.values["usr_123"] | |
| assert await store.load() == "spotify-refresh-token" | |
| repository.values["usr_other"] = repository.values["usr_123"] | |
| swapped = EncryptedRefreshTokenStore(repository, cipher, "usr_other") | |
| with pytest.raises(AuthenticationError, match="could not be decrypted"): | |
| await swapped.load() | |
| async def test_hosted_spotify_oauth_uses_pkce_and_persists_only_refresh_token() -> None: | |
| repository = MemoryRepository() | |
| settings = hosted_settings() | |
| async def handler(request: httpx2.Request) -> httpx2.Response: | |
| assert str(request.url) == "https://accounts.spotify.test/api/token" | |
| assert b"grant_type=authorization_code" in request.content | |
| assert b"code_verifier=" in request.content | |
| return httpx2.Response( | |
| 200, | |
| json={ | |
| "access_token": "memory-only-access", | |
| "refresh_token": "persisted-refresh", | |
| "expires_in": 3600, | |
| }, | |
| ) | |
| async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: | |
| services = HostedSpotifyServices( | |
| settings, | |
| repository=repository, | |
| http_client=http, | |
| ) | |
| connect_url = services.spotify_connect_url("usr_123") | |
| sealed_ticket = parse_qs(urlparse(connect_url).query)["ticket"][0] | |
| authorization_url = services.spotify_authorization_url(sealed_ticket) | |
| authorization_query = parse_qs(urlparse(authorization_url).query) | |
| assert authorization_query["state"] == [sealed_ticket] | |
| assert authorization_query["code_challenge_method"] == ["S256"] | |
| assert authorization_query["scope"][0].split() == list(SCOPES) | |
| await services.complete_spotify_authorization("spotify-code", sealed_ticket) | |
| ciphertext = repository.values["usr_123"] | |
| assert b"persisted-refresh" not in ciphertext | |
| assert b"memory-only-access" not in ciphertext | |
| store = EncryptedRefreshTokenStore(repository, services.cipher, "usr_123") | |
| assert await store.load() == "persisted-refresh" | |
| async def test_hosted_service_requires_connection_and_honors_subject_allowlist() -> None: | |
| repository = MemoryRepository() | |
| services = HostedSpotifyServices( | |
| hosted_settings(allowed_subjects=frozenset({"usr_allowed"})), | |
| repository=repository, | |
| http_client=httpx2.AsyncClient(), | |
| ) | |
| try: | |
| with pytest.raises(SpotifyUserNotAllowed): | |
| await services.service_for("usr_denied") | |
| with pytest.raises(SpotifyConnectionRequired, match="/spotify/connect"): | |
| await services.service_for("usr_allowed") | |
| finally: | |
| await services.http.aclose() | |
| async def test_protected_resource_metadata_and_bearer_challenge() -> None: | |
| class NeverCalledVerifier: | |
| async def verify_token(self, token: str) -> AccessToken | None: | |
| raise AssertionError(f"unexpected token: {token}") | |
| settings = hosted_settings() | |
| server = create_server( | |
| service=object(), # type: ignore[arg-type] | |
| token_verifier=NeverCalledVerifier(), | |
| auth=settings.auth_settings(), | |
| ) | |
| app = server.streamable_http_app( | |
| stateless_http=True, json_response=True, host="spotify.example" | |
| ) | |
| async with httpx2.AsyncClient( | |
| transport=httpx2.ASGITransport(app=app), | |
| base_url="https://spotify.example", | |
| ) as client: | |
| metadata = await client.get("/.well-known/oauth-protected-resource/mcp") | |
| challenge = await client.post("/mcp", json={}) | |
| assert metadata.status_code == 200 | |
| assert metadata.json()["resource"] == "https://spotify.example/mcp" | |
| assert metadata.json()["authorization_servers"] == [ | |
| "https://tenant.scalekit.dev/resources/res_123" | |
| ] | |
| assert challenge.status_code == 401 | |
| assert ( | |
| 'resource_metadata="https://spotify.example/.well-known/oauth-protected-resource/mcp"' | |
| in challenge.headers["www-authenticate"] | |
| ) | |
| def test_neon_dsn_removes_libpq_only_channel_binding() -> None: | |
| source = "postgresql://user:pass@pooler.example/db?sslmode=require&channel_binding=require" | |
| assert _asyncpg_dsn(source) == "postgresql://user:pass@pooler.example/db?sslmode=require" | |
| async def test_cipher_rejects_tampered_spotify_ticket() -> None: | |
| http = httpx2.AsyncClient() | |
| services = HostedSpotifyServices( | |
| hosted_settings(), | |
| repository=MemoryRepository(), | |
| http_client=http, | |
| ) | |
| try: | |
| with pytest.raises(AuthenticationError, match="invalid or expired"): | |
| services.spotify_authorization_url("tampered") | |
| finally: | |
| await http.aclose() | |