# Valuable references for the reviewer: - Docs PR: https://github.com/ArcadeAI/docs/pull/583 - Implements Phase 1 of the following planning doc: https://linear.app/arcadedev/project/arcade-mcp-supports-mcp-auth-front-door-auth-7cbaa20cb054/overview https://github.com/user-attachments/assets/79ad43fd-f5e8-4793-a1dd-18b35acefdc3 # PR Description Adds OAuth 2.1 Resource Server authentication to arcade-mcp-server, enabling HTTP MCP servers to validate Bearer tokens on every request. This unlocks tool-level authorization and secrets support for HTTP servers. - Multiple authorization server support - Granular token validation options (verify_exp, verify_iat, verify_iss) - Environment variable configuration - OAuth discovery metadata endpoint (/.well-known/oauth-protected-resource) - Extracts sub claim from token as context.user_id - Lifts transport restrictions for tools requiring auth/secrets on HTTP when protected ```python from arcade_mcp_server import MCPApp from arcade_mcp_server.resource_server import ResourceServerAuth, AuthorizationServerEntry resource_server_auth = ResourceServerAuth( canonical_url="http://127.0.0.1:8000/mcp", authorization_servers=[ AuthorizationServerEntry( authorization_server_url="https://auth.example.com", issuer="https://auth.example.com", jwks_uri="https://auth.example.com/jwks", ) ], ) app = MCPApp(name="my_server", version="1.0.0", auth=resource_server_auth) ``` # Testing Beyond the comprehensive unit tests, I also manually tested end-to-end with WorkOS Authkit (DCR) and KeyCloak (non-DCR). # Future Work - CIMD support - An `ArcadeResourceServer` to make adding front-door auth super easy when using Arcade's Auth Server <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds OAuth 2.1 front-door auth (JWKS validation + OAuth discovery) and propagates user identity to tools, enabling auth/secret-requiring tools over HTTP. > > - **Authentication (Front-Door OAuth 2.1)** > - New `resource_server` module with `ResourceServerAuth` (multi-authorization-server, metadata) and `JWKSTokenValidator` (JWKS-based JWT validation) plus granular validation options. > - ASGI `ResourceServerMiddleware` validates Bearer tokens on every HTTP request and injects `resource_owner`. > - OAuth discovery endpoint via FastAPI router at `/.well-known/oauth-protected-resource[/<path>]`. > - **Integration** > - `MCPApp`/`worker` accept `auth`/`resource_server_validator`, mount middleware, expose discovery; logs accepted auth servers. > - HTTP transport (`http_streamable`) carries `SessionMessage` with `resource_owner` from request → session. > - `Context`/`Session`/`Server` plumb `resource_owner`; `Server` selects `user_id` preferring token `sub`. > - **Behavior Changes** > - HTTP transport restriction lifted for tools requiring `authorization`/`secrets` when request is authenticated; otherwise blocked with actionable error. > - **Configuration** > - Env-var based auth config via `MCP_RESOURCE_SERVER_*` in `MCPSettings.ResourceServerSettings`; `.env` auto-load. > - **Telemetry** > - Usage tracking records `resource_server_type` on server start. > - **Examples** > - New `examples/mcp_servers/authorization` sample server (HTTP auth, secrets, Reddit tool) with Docker setup. > - **Tests** > - Extensive unit tests for validators, middleware, env config, multi-AS, transport rules, and app integration. > - **Version** > - Bump `arcade-mcp-server` to `1.12.0`; minor docstring tweak in `__init__.py`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d1116cdcafb0c7cb8f91e66682eb1fbae380da31. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> Resolves TOO-152
1246 lines
46 KiB
Python
1246 lines
46 KiB
Python
import base64
|
|
import time
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
from arcade_core.catalog import ToolCatalog
|
|
from arcade_mcp_server.resource_server import (
|
|
AccessTokenValidationOptions,
|
|
AuthorizationServerEntry,
|
|
JWKSTokenValidator,
|
|
ResourceServerAuth,
|
|
)
|
|
from arcade_mcp_server.resource_server.base import (
|
|
InvalidTokenError,
|
|
ResourceOwner,
|
|
TokenExpiredError,
|
|
)
|
|
from arcade_mcp_server.resource_server.middleware import ResourceServerMiddleware
|
|
from arcade_mcp_server.worker import create_arcade_mcp
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from jose import jwt
|
|
|
|
|
|
# Test fixtures
|
|
@pytest.fixture(autouse=True)
|
|
def clean_auth_env(monkeypatch):
|
|
"""Clean server auth environment variables before each test."""
|
|
env_vars = [
|
|
"MCP_RESOURCE_SERVER_CANONICAL_URL",
|
|
"MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS",
|
|
]
|
|
|
|
for var in env_vars:
|
|
monkeypatch.delenv(var, raising=False)
|
|
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def rsa_keypair():
|
|
"""Generate RSA key pair for testing."""
|
|
private_key = rsa.generate_private_key(
|
|
public_exponent=65537,
|
|
key_size=2048,
|
|
)
|
|
public_key = private_key.public_key()
|
|
|
|
return private_key, public_key
|
|
|
|
|
|
@pytest.fixture
|
|
def serialized_private_key(rsa_keypair):
|
|
"""Generate private key as PEM format for testing."""
|
|
private_key, _ = rsa_keypair
|
|
# Serialize private key to PEM format for python-jose
|
|
pem = private_key.private_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PrivateFormat.PKCS8,
|
|
encryption_algorithm=serialization.NoEncryption(),
|
|
)
|
|
|
|
return pem
|
|
|
|
|
|
@pytest.fixture
|
|
def jwks_data(rsa_keypair):
|
|
"""Generate JWKS data for testing."""
|
|
_, public_key = rsa_keypair
|
|
|
|
# Export public key in JWK format
|
|
public_numbers = public_key.public_numbers()
|
|
n = public_numbers.n
|
|
e = public_numbers.e
|
|
|
|
# Convert to base64url
|
|
n_bytes = n.to_bytes((n.bit_length() + 7) // 8, byteorder="big")
|
|
e_bytes = e.to_bytes((e.bit_length() + 7) // 8, byteorder="big")
|
|
n_b64 = base64.urlsafe_b64encode(n_bytes).decode("utf-8").rstrip("=")
|
|
e_b64 = base64.urlsafe_b64encode(e_bytes).decode("utf-8").rstrip("=")
|
|
|
|
return {
|
|
"keys": [
|
|
{
|
|
"kty": "RSA",
|
|
"kid": "test-key-1",
|
|
"use": "sig",
|
|
"alg": "RS256",
|
|
"n": n_b64,
|
|
"e": e_b64,
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def valid_jwt_token(rsa_keypair):
|
|
"""Generate valid JWT token for testing."""
|
|
private_key, _ = rsa_keypair
|
|
|
|
payload = {
|
|
"sub": "user123",
|
|
"email": "user@example.com",
|
|
"iss": "https://auth.example.com",
|
|
"aud": "https://mcp.example.com/mcp",
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
return token
|
|
|
|
|
|
@pytest.fixture
|
|
def expired_jwt_token(rsa_keypair):
|
|
"""Generate expired JWT token for testing."""
|
|
private_key, _ = rsa_keypair
|
|
|
|
payload = {
|
|
"sub": "user123",
|
|
"email": "user@example.com",
|
|
"iss": "https://auth.example.com",
|
|
"aud": "https://mcp.example.com/mcp",
|
|
"exp": int(time.time()) - 3600,
|
|
"iat": int(time.time()) - 7200,
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
return token
|
|
|
|
|
|
class TestJWKSTokenValidator:
|
|
"""Tests for JWKSTokenValidator class."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_valid_token(self, valid_jwt_token, jwks_data):
|
|
"""Test validating a valid JWT token."""
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience="https://mcp.example.com/mcp",
|
|
)
|
|
|
|
user = await validator.validate_token(valid_jwt_token)
|
|
|
|
assert isinstance(user, ResourceOwner)
|
|
assert user.user_id == "user123"
|
|
assert user.email == "user@example.com"
|
|
assert user.claims["iss"] == "https://auth.example.com"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_expired_token(self, expired_jwt_token, jwks_data):
|
|
"""Test validating an expired JWT token."""
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience="https://mcp.example.com/mcp",
|
|
)
|
|
|
|
with pytest.raises(TokenExpiredError):
|
|
await validator.validate_token(expired_jwt_token)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_wrong_audience(self, serialized_private_key, jwks_data):
|
|
"""Test validating token with wrong audience."""
|
|
payload = {
|
|
"sub": "user123",
|
|
"iss": "https://auth.example.com",
|
|
"aud": "https://wrong-server.com", # Wrong audience
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
serialized_private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience="https://mcp.example.com/mcp",
|
|
)
|
|
|
|
with pytest.raises(InvalidTokenError, match="audience"):
|
|
await validator.validate_token(token)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_wrong_issuer(self, serialized_private_key, jwks_data):
|
|
"""Test validating token with wrong issuer."""
|
|
payload = {
|
|
"sub": "user123",
|
|
"iss": "https://wrong-issuer.com", # Wrong issuer
|
|
"aud": "https://mcp.example.com/mcp",
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
serialized_private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience="https://mcp.example.com/mcp",
|
|
)
|
|
|
|
with pytest.raises(InvalidTokenError, match="issuer"):
|
|
await validator.validate_token(token)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_missing_sub_claim(self, serialized_private_key, jwks_data):
|
|
"""Test validating token without sub claim."""
|
|
payload = {
|
|
"iss": "https://auth.example.com",
|
|
"aud": "https://mcp.example.com/mcp",
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
serialized_private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience="https://mcp.example.com/mcp",
|
|
)
|
|
|
|
with pytest.raises(InvalidTokenError, match="sub"):
|
|
await validator.validate_token(token)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_jwks_caching(self, valid_jwt_token, jwks_data):
|
|
"""Test that JWKS is cached to avoid repeated fetches."""
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience="https://mcp.example.com/mcp",
|
|
cache_ttl=3600,
|
|
)
|
|
|
|
# First validation should fetch JWKS
|
|
await validator.validate_token(valid_jwt_token)
|
|
assert mock_get.call_count == 1
|
|
|
|
# Second validation should use cached JWKS
|
|
await validator.validate_token(valid_jwt_token)
|
|
assert mock_get.call_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_multiple_audiences_single_token_aud(
|
|
self, serialized_private_key, jwks_data
|
|
):
|
|
"""Test validator with multiple audiences accepts token with matching single aud."""
|
|
payload = {
|
|
"sub": "user123",
|
|
"iss": "https://auth.example.com",
|
|
"aud": "https://old-mcp.example.com", # Matches first audience
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
serialized_private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience=["https://old-mcp.example.com", "https://new-mcp.example.com"],
|
|
)
|
|
|
|
user = await validator.validate_token(token)
|
|
assert user.user_id == "user123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_multiple_audiences_list_token_aud(
|
|
self, serialized_private_key, jwks_data
|
|
):
|
|
"""Test validator with multiple audiences accepts token with list aud."""
|
|
# Token with list of audiences where one matches the validator's accepted audiences
|
|
payload = {
|
|
"sub": "user123",
|
|
"iss": "https://auth.example.com",
|
|
"aud": ["https://api1.com", "https://new-mcp.example.com"], # Second matches
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
serialized_private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience=["https://old-mcp.example.com", "https://new-mcp.example.com"],
|
|
)
|
|
|
|
user = await validator.validate_token(token)
|
|
assert user.user_id == "user123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_multiple_audiences_no_match(self, serialized_private_key, jwks_data):
|
|
"""Test validator with multiple audiences rejects token with non-matching aud."""
|
|
# Token with audience that doesn't match any of validator's accepted audiences
|
|
payload = {
|
|
"sub": "user123",
|
|
"iss": "https://auth.example.com",
|
|
"aud": "https://different-server.com", # Doesn't match
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
serialized_private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience=["https://old-mcp.example.com", "https://new-mcp.example.com"],
|
|
)
|
|
|
|
with pytest.raises(InvalidTokenError, match="audience"):
|
|
await validator.validate_token(token)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_single_audience_with_list_token_aud(
|
|
self, serialized_private_key, jwks_data
|
|
):
|
|
"""Test validator with single audience accepts token with list aud containing match."""
|
|
# Token with list of audiences where one matches validator's single audience
|
|
payload = {
|
|
"sub": "user123",
|
|
"iss": "https://auth.example.com",
|
|
"aud": ["https://api1.com", "https://mcp.example.com/mcp", "https://api2.com"],
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
serialized_private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience="https://mcp.example.com/mcp", # Single audience
|
|
)
|
|
|
|
user = await validator.validate_token(token)
|
|
assert user.user_id == "user123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_multiple_issuers_efficient(self, serialized_private_key, jwks_data):
|
|
"""Test that multi-issuer validation is efficient (single decode)."""
|
|
# Token from second issuer in list
|
|
payload = {
|
|
"sub": "user123",
|
|
"iss": "https://auth2.example.com", # Second in list
|
|
"aud": "https://mcp.example.com/mcp",
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
serialized_private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
# Patch jwt.decode to count calls
|
|
with patch(
|
|
"arcade_mcp_server.resource_server.validators.jwks.jwt.decode", wraps=jwt.decode
|
|
) as mock_decode:
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer=[
|
|
"https://auth1.example.com",
|
|
"https://auth2.example.com",
|
|
"https://auth3.example.com",
|
|
],
|
|
audience="https://mcp.example.com/mcp",
|
|
)
|
|
|
|
user = await validator.validate_token(token)
|
|
assert user.user_id == "user123"
|
|
|
|
# Should only need to decode once, not 3 times
|
|
assert mock_decode.call_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_nbf_claim_before_time(self, serialized_private_key, jwks_data):
|
|
"""Test that token with nbf claim in the future is rejected."""
|
|
payload = {
|
|
"sub": "user123",
|
|
"iss": "https://auth.example.com",
|
|
"aud": "https://mcp.example.com/mcp",
|
|
"exp": int(time.time()) + 7200, # expires in 2 hours
|
|
"iat": int(time.time()),
|
|
"nbf": int(time.time()) + 3600, # Not valid for 1 hour
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
serialized_private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience="https://mcp.example.com/mcp",
|
|
validation_options=AccessTokenValidationOptions(verify_nbf=True),
|
|
)
|
|
|
|
with pytest.raises(InvalidTokenError):
|
|
await validator.validate_token(token)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_nbf_claim_disabled(self, serialized_private_key, jwks_data):
|
|
"""Test that token with nbf in future is accepted when verify_nbf=False."""
|
|
payload = {
|
|
"sub": "user123",
|
|
"iss": "https://auth.example.com",
|
|
"aud": "https://mcp.example.com/mcp",
|
|
"exp": int(time.time()) + 7200, # expires in 2 hours
|
|
"iat": int(time.time()),
|
|
"nbf": int(time.time()) + 3600, # Not valid for 1 hour
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
serialized_private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience="https://mcp.example.com/mcp",
|
|
validation_options=AccessTokenValidationOptions(verify_nbf=False),
|
|
)
|
|
|
|
# Should accept the token when nbf verification is disabled
|
|
user = await validator.validate_token(token)
|
|
assert user.user_id == "user123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_with_leeway(self, serialized_private_key, jwks_data):
|
|
"""Test that leeway allows slightly expired tokens."""
|
|
# Token expired 30 seconds ago
|
|
payload = {
|
|
"sub": "user123",
|
|
"iss": "https://auth.example.com",
|
|
"aud": "https://mcp.example.com/mcp",
|
|
"exp": int(time.time()) - 30,
|
|
"iat": int(time.time()) - 3600,
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
serialized_private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
# Validator with 60 second leeway should accept this token
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience="https://mcp.example.com/mcp",
|
|
validation_options=AccessTokenValidationOptions(leeway=60),
|
|
)
|
|
|
|
user = await validator.validate_token(token)
|
|
assert user.user_id == "user123"
|
|
|
|
|
|
# ResourceServerAuth Tests
|
|
class TestResourceServerAuth:
|
|
"""Tests for ResourceServerAuth class."""
|
|
|
|
def test_supports_oauth_discovery(self):
|
|
"""Test that ResourceServerAuth supports OAuth discovery."""
|
|
resource_server_auth = ResourceServerAuth(
|
|
canonical_url="https://mcp.example.com/mcp",
|
|
authorization_servers=[
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth.example.com",
|
|
issuer="https://auth.example.com",
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
)
|
|
],
|
|
)
|
|
|
|
assert resource_server_auth.supports_oauth_discovery() is True
|
|
|
|
def test_get_resource_metadata(self):
|
|
"""Test getting OAuth Protected Resource Metadata."""
|
|
resource_server_auth = ResourceServerAuth(
|
|
canonical_url="https://mcp.example.com/mcp",
|
|
authorization_servers=[
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth.example.com",
|
|
issuer="https://auth.example.com",
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
)
|
|
],
|
|
)
|
|
|
|
metadata = resource_server_auth.get_resource_metadata()
|
|
|
|
assert metadata["resource"] == "https://mcp.example.com/mcp"
|
|
assert metadata["authorization_servers"] == ["https://auth.example.com"]
|
|
assert metadata["bearer_methods_supported"] == ["header"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_expected_audiences_override(self, rsa_keypair, jwks_data):
|
|
"""Test that expected_audiences overrides canonical_url for audience validation."""
|
|
private_key, _ = rsa_keypair
|
|
|
|
# Token with custom audience
|
|
payload = {
|
|
"sub": "user123",
|
|
"iss": "https://auth.example.com",
|
|
"aud": "my-authkit-client-id",
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
resource_server_auth = ResourceServerAuth(
|
|
canonical_url="https://mcp.example.com/mcp",
|
|
authorization_servers=[
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth.example.com",
|
|
issuer="https://auth.example.com",
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
expected_audiences=["my-authkit-client-id"],
|
|
)
|
|
],
|
|
)
|
|
|
|
user = await resource_server_auth.validate_token(token)
|
|
assert user.user_id == "user123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_expected_audiences_multiple_values(self, rsa_keypair, jwks_data):
|
|
"""Test that multiple expected_audiences work correctly."""
|
|
private_key, _ = rsa_keypair
|
|
|
|
# Token with one of the expected audiences
|
|
payload = {
|
|
"sub": "user123",
|
|
"iss": "https://auth.example.com",
|
|
"aud": "secondary-client-id",
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
resource_server_auth = ResourceServerAuth(
|
|
canonical_url="https://mcp.example.com/mcp",
|
|
authorization_servers=[
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth.example.com",
|
|
issuer="https://auth.example.com",
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
expected_audiences=[
|
|
"primary-client-id",
|
|
"secondary-client-id",
|
|
"tertiary-client-id",
|
|
],
|
|
)
|
|
],
|
|
)
|
|
|
|
user = await resource_server_auth.validate_token(token)
|
|
assert user.user_id == "user123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_expected_audiences_defaults_to_canonical_url(self, rsa_keypair, jwks_data):
|
|
"""Test that without expected_audiences, canonical_url is used for audience validation."""
|
|
private_key, _ = rsa_keypair
|
|
|
|
payload = {
|
|
"sub": "user123",
|
|
"iss": "https://auth.example.com",
|
|
"aud": "https://mcp.example.com/mcp",
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
resource_server_auth = ResourceServerAuth(
|
|
canonical_url="https://mcp.example.com/mcp",
|
|
authorization_servers=[
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth.example.com",
|
|
issuer="https://auth.example.com",
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
)
|
|
],
|
|
)
|
|
|
|
user = await resource_server_auth.validate_token(token)
|
|
assert user.user_id == "user123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_expected_audiences_wrong_audience_rejected(self, rsa_keypair, jwks_data):
|
|
"""Test that tokens with wrong audience are rejected even with expected_audiences."""
|
|
private_key, _ = rsa_keypair
|
|
|
|
payload = {
|
|
"sub": "user123",
|
|
"iss": "https://auth.example.com",
|
|
"aud": "wrong-client-id", # Not in expected_audiences list
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
|
|
token = jwt.encode(
|
|
payload,
|
|
private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
resource_server_auth = ResourceServerAuth(
|
|
canonical_url="https://mcp.example.com/mcp",
|
|
authorization_servers=[
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth.example.com",
|
|
issuer="https://auth.example.com",
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
expected_audiences=["correct-client-id"],
|
|
)
|
|
],
|
|
)
|
|
|
|
with pytest.raises(InvalidTokenError):
|
|
await resource_server_auth.validate_token(token)
|
|
|
|
|
|
# ResourceServerMiddleware Tests
|
|
class TestResourceServerMiddleware:
|
|
"""Tests for ResourceServerMiddleware class."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticated_request(self, valid_jwt_token, jwks_data):
|
|
"""Test authenticated request passes through."""
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience="https://mcp.example.com/mcp",
|
|
)
|
|
|
|
# Mock app
|
|
app_called = False
|
|
|
|
async def mock_app(scope, receive, send):
|
|
nonlocal app_called
|
|
app_called = True
|
|
assert "resource_owner" in scope
|
|
assert scope["resource_owner"].user_id == "user123"
|
|
|
|
middleware = ResourceServerMiddleware(
|
|
mock_app,
|
|
validator,
|
|
"https://mcp.example.com/mcp",
|
|
)
|
|
|
|
# Create mock request
|
|
scope = {
|
|
"type": "http",
|
|
"method": "POST",
|
|
"headers": [(b"authorization", f"Bearer {valid_jwt_token}".encode())],
|
|
}
|
|
|
|
async def receive():
|
|
return {"type": "http.request", "body": b""}
|
|
|
|
async def send(message):
|
|
pass
|
|
|
|
await middleware(scope, receive, send)
|
|
assert app_called is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_authorization_header(self, jwks_data):
|
|
"""Test request without Authorization header returns 401."""
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
audience="https://mcp.example.com/mcp",
|
|
)
|
|
|
|
async def mock_app(scope, receive, send):
|
|
pytest.fail("App should not be called")
|
|
|
|
middleware = ResourceServerMiddleware(
|
|
mock_app,
|
|
validator,
|
|
"https://mcp.example.com/mcp",
|
|
)
|
|
|
|
# mock request w/o auth header
|
|
scope = {
|
|
"type": "http",
|
|
"method": "POST",
|
|
"headers": [],
|
|
}
|
|
|
|
async def receive():
|
|
return {"type": "http.request", "body": b""}
|
|
|
|
response_sent = {}
|
|
|
|
async def send(message):
|
|
if message["type"] == "http.response.start":
|
|
response_sent["status"] = message["status"]
|
|
response_sent["headers"] = dict(message.get("headers", []))
|
|
|
|
await middleware(scope, receive, send)
|
|
|
|
assert response_sent["status"] == 401
|
|
assert any(k.lower() == b"www-authenticate" for k in response_sent["headers"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_www_authenticate_header_format(self, jwks_data):
|
|
"""Test WWW-Authenticate header format compliance."""
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
resource_server = ResourceServerAuth(
|
|
canonical_url="https://mcp.example.com/mcp",
|
|
authorization_servers=[
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth.example.com",
|
|
issuer="https://auth.example.com",
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
)
|
|
],
|
|
)
|
|
|
|
async def mock_app(scope, receive, send):
|
|
pytest.fail("App should not be called")
|
|
|
|
middleware = ResourceServerMiddleware(
|
|
mock_app,
|
|
resource_server,
|
|
"https://mcp.example.com/mcp",
|
|
)
|
|
|
|
scope = {
|
|
"type": "http",
|
|
"method": "POST",
|
|
"headers": [],
|
|
}
|
|
|
|
async def receive():
|
|
return {"type": "http.request", "body": b""}
|
|
|
|
response_headers = {}
|
|
|
|
async def send(message):
|
|
if message["type"] == "http.response.start":
|
|
response_headers.update(dict(message.get("headers", [])))
|
|
|
|
await middleware(scope, receive, send)
|
|
|
|
www_auth = response_headers.get(b"www-authenticate", b"").decode()
|
|
|
|
assert "Bearer" in www_auth
|
|
assert "resource_metadata=" in www_auth
|
|
assert "/.well-known/oauth-protected-resource" in www_auth
|
|
|
|
|
|
class TestEnvVarConfiguration:
|
|
"""Tests for front-door auth env var configuration support."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_server_param_precedence(self, monkeypatch):
|
|
"""Test that explicit parameters take precedence over environment variables."""
|
|
monkeypatch.setenv("MCP_RESOURCE_SERVER_CANONICAL_URL", "https://env-mcp.example.com")
|
|
monkeypatch.setenv(
|
|
"MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS",
|
|
'[{"authorization_server_url":"https://env.example.com","issuer":"https://env.example.com","jwks_uri":"https://env.example.com/jwks"}]',
|
|
)
|
|
|
|
resource_server = ResourceServerAuth(
|
|
canonical_url="https://param-mcp.example.com",
|
|
authorization_servers=[
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://param.example.com",
|
|
issuer="https://param.example.com",
|
|
jwks_uri="https://param.example.com/jwks",
|
|
)
|
|
],
|
|
)
|
|
|
|
# Explicit parameters should take precedence over env vars
|
|
assert resource_server.canonical_url == "https://param-mcp.example.com"
|
|
metadata = resource_server.get_resource_metadata()
|
|
assert metadata["authorization_servers"] == ["https://param.example.com"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_server_all_env_vars(self, monkeypatch):
|
|
"""Test ResourceServerAuth with all env vars, no parameters."""
|
|
monkeypatch.setenv("MCP_RESOURCE_SERVER_CANONICAL_URL", "https://mcp.example.com/mcp")
|
|
monkeypatch.setenv(
|
|
"MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS",
|
|
'[{"authorization_server_url":"https://auth.example.com","issuer":"https://auth.example.com","jwks_uri":"https://auth.example.com/jwks","algorithm":"RS256","expected_audiences":["custom-client-id"]}]',
|
|
)
|
|
|
|
resource_server_auth = ResourceServerAuth()
|
|
|
|
assert resource_server_auth.canonical_url == "https://mcp.example.com/mcp"
|
|
metadata = resource_server_auth.get_resource_metadata()
|
|
assert metadata["authorization_servers"] == ["https://auth.example.com"]
|
|
|
|
def test_resource_server_missing_required(self):
|
|
"""Test that missing required fields raise ValueError."""
|
|
with pytest.raises(ValueError, match="'canonical_url' required"):
|
|
ResourceServerAuth(
|
|
authorization_servers=[
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth.example.com",
|
|
issuer="https://auth.example.com",
|
|
jwks_uri="https://auth.example.com/jwks",
|
|
)
|
|
],
|
|
# Missing canonical_url
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_worker_no_canonical_url_for_jwks_validator(self):
|
|
"""Test that worker doesn't require canonical_url for JWKSTokenValidator."""
|
|
jwt_validator = JWKSTokenValidator(
|
|
jwks_uri="https://auth.example.com/jwks",
|
|
issuer="https://auth.example.com",
|
|
audience="https://mcp.example.com/mcp",
|
|
)
|
|
|
|
catalog = ToolCatalog()
|
|
# Shouldn't raise b/c JWKSTokenValidator doesn't support OAuth discovery
|
|
app = create_arcade_mcp(catalog, resource_server_validator=jwt_validator)
|
|
assert app is not None
|
|
|
|
def test_worker_requires_canonical_url_for_resource_server(self):
|
|
"""Test that ResourceServerAuth validation happens during init."""
|
|
with pytest.raises(ValueError, match="'canonical_url' required"):
|
|
ResourceServerAuth(
|
|
authorization_servers=[
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth.example.com",
|
|
issuer="https://auth.example.com",
|
|
jwks_uri="https://auth.example.com/jwks",
|
|
)
|
|
],
|
|
# Missing canonical_url
|
|
)
|
|
|
|
|
|
class TestMultipleAuthorizationServers:
|
|
"""Tests for multiple authorization server support."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_server_multiple_as_shared_jwks(self, jwks_data, valid_jwt_token):
|
|
"""Test multiple AS URLs with same JWKS"""
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
resource_server_auth = ResourceServerAuth(
|
|
canonical_url="https://mcp.example.com/mcp",
|
|
authorization_servers=[
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth-us.example.com",
|
|
issuer="https://auth.example.com",
|
|
jwks_uri="https://auth.example.com/jwks",
|
|
),
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth-eu.example.com",
|
|
issuer="https://auth.example.com",
|
|
jwks_uri="https://auth.example.com/jwks",
|
|
),
|
|
],
|
|
)
|
|
|
|
# Verify that metadata returns all Auth Server URLs
|
|
metadata = resource_server_auth.get_resource_metadata()
|
|
assert metadata["resource"] == "https://mcp.example.com/mcp"
|
|
assert metadata["authorization_servers"] == [
|
|
"https://auth-us.example.com",
|
|
"https://auth-eu.example.com",
|
|
]
|
|
|
|
# Verify that token validation works
|
|
user = await resource_server_auth.validate_token(valid_jwt_token)
|
|
assert user.user_id == "user123"
|
|
assert user.email == "user@example.com"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_server_multiple_as_different_jwks(self, rsa_keypair, jwks_data):
|
|
"""Test multiple AS with different JWKS (multi-IdP)."""
|
|
private_key, _ = rsa_keypair
|
|
|
|
payload1 = {
|
|
"sub": "user123",
|
|
"email": "user@workos.com",
|
|
"iss": "https://workos.authkit.app",
|
|
"aud": "https://mcp.example.com/mcp",
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
token1 = jwt.encode(
|
|
payload1,
|
|
private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
payload2 = {
|
|
"sub": "user456",
|
|
"email": "user@keycloak.com",
|
|
"iss": "http://localhost:8080/realms/mcp-test",
|
|
"aud": "https://mcp.example.com/mcp",
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
token2 = jwt.encode(
|
|
payload2,
|
|
private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
resource_server_auth = ResourceServerAuth(
|
|
canonical_url="https://mcp.example.com/mcp",
|
|
authorization_servers=[
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://workos.authkit.app",
|
|
issuer="https://workos.authkit.app",
|
|
jwks_uri="https://workos.authkit.app/oauth2/jwks",
|
|
),
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="http://localhost:8080/realms/mcp-test",
|
|
issuer="http://localhost:8080/realms/mcp-test",
|
|
jwks_uri="http://localhost:8080/realms/mcp-test/protocol/openid-connect/certs",
|
|
algorithm="RS256",
|
|
),
|
|
],
|
|
)
|
|
|
|
# Verify metadata returns all Auth Server URLs
|
|
metadata = resource_server_auth.get_resource_metadata()
|
|
assert metadata["authorization_servers"] == [
|
|
"https://workos.authkit.app",
|
|
"http://localhost:8080/realms/mcp-test",
|
|
]
|
|
|
|
# Verify tokens from both Auth Servers work
|
|
user1 = await resource_server_auth.validate_token(token1)
|
|
assert user1.user_id == "user123"
|
|
assert user1.email == "user@workos.com"
|
|
|
|
user2 = await resource_server_auth.validate_token(token2)
|
|
assert user2.user_id == "user456"
|
|
assert user2.email == "user@keycloak.com"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_server_rejects_unconfigured_as(self, rsa_keypair, jwks_data):
|
|
"""Test that tokens from unlisted AS are rejected."""
|
|
private_key, _ = rsa_keypair
|
|
|
|
payload = {
|
|
"sub": "user123",
|
|
"email": "user@evil.com",
|
|
"iss": "https://evil.com", # Not in configured list (unauthorized issuer)
|
|
"aud": "https://mcp.example.com/mcp",
|
|
"exp": int(time.time()) + 3600,
|
|
"iat": int(time.time()),
|
|
}
|
|
token = jwt.encode(
|
|
payload,
|
|
private_key,
|
|
algorithm="RS256",
|
|
headers={"kid": "test-key-1"},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.json.return_value = jwks_data
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
resource_server_auth = ResourceServerAuth(
|
|
canonical_url="https://mcp.example.com/mcp",
|
|
authorization_servers=[
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth.example.com",
|
|
issuer="https://auth.example.com",
|
|
jwks_uri="https://auth.example.com/jwks",
|
|
)
|
|
],
|
|
)
|
|
|
|
# Should reject token from unauthorized Auth Server (issuer)
|
|
with pytest.raises(
|
|
InvalidTokenError,
|
|
match="Token validation failed for all configured authorization servers",
|
|
):
|
|
await resource_server_auth.validate_token(token)
|
|
|
|
def test_authorization_servers_env_var_parsing_json(self, monkeypatch):
|
|
"""Test parsing JSON array of AS configs from env var."""
|
|
monkeypatch.setenv("MCP_RESOURCE_SERVER_CANONICAL_URL", "https://mcp.example.com/mcp")
|
|
monkeypatch.setenv(
|
|
"MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS",
|
|
'[{"authorization_server_url": "https://auth1.com", "issuer": "https://auth1.com", "jwks_uri": "https://auth1.com/jwks"}]',
|
|
)
|
|
|
|
resource_server_auth = ResourceServerAuth()
|
|
|
|
metadata = resource_server_auth.get_resource_metadata()
|
|
assert metadata["authorization_servers"] == ["https://auth1.com"]
|
|
|
|
def test_resource_metadata_multiple_as(self):
|
|
"""Test that resource metadata returns all AS URLs."""
|
|
resource_server_auth = ResourceServerAuth(
|
|
canonical_url="https://mcp.example.com/mcp",
|
|
authorization_servers=[
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth1.example.com",
|
|
issuer="https://auth1.example.com",
|
|
jwks_uri="https://auth1.example.com/jwks",
|
|
),
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth2.example.com",
|
|
issuer="https://auth2.example.com",
|
|
jwks_uri="https://auth2.example.com/jwks",
|
|
),
|
|
AuthorizationServerEntry(
|
|
authorization_server_url="https://auth3.example.com",
|
|
issuer="https://auth3.example.com",
|
|
jwks_uri="https://auth3.example.com/jwks",
|
|
),
|
|
],
|
|
)
|
|
|
|
metadata = resource_server_auth.get_resource_metadata()
|
|
assert metadata["resource"] == "https://mcp.example.com/mcp"
|
|
assert len(metadata["authorization_servers"]) == 3
|
|
assert "https://auth1.example.com" in metadata["authorization_servers"]
|
|
assert "https://auth2.example.com" in metadata["authorization_servers"]
|
|
assert "https://auth3.example.com" in metadata["authorization_servers"]
|