spotify-mcp-server / tests /test_hosted.py
LeoWalker's picture
Throttle JWKS refreshes during MCP token verification (#7)
f48d12a unverified
Raw
History Blame Contribute Delete
13.6 kB
"""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]
@staticmethod
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()