chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"""Tests for the AuthContext middleware components."""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from starlette.types import Message, Receive, Scope, Send
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import (
|
||||
AuthContextMiddleware,
|
||||
auth_context_var,
|
||||
get_access_token,
|
||||
)
|
||||
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
|
||||
|
||||
class MockApp:
|
||||
"""Mock ASGI app for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
self.scope: Scope | None = None
|
||||
self.receive: Receive | None = None
|
||||
self.send: Send | None = None
|
||||
self.access_token_during_call: AccessToken | None = None
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
self.called = True
|
||||
self.scope = scope
|
||||
self.receive = receive
|
||||
self.send = send
|
||||
# Check the context during the call
|
||||
self.access_token_during_call = get_access_token()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_access_token() -> AccessToken:
|
||||
"""Create a valid access token."""
|
||||
return AccessToken(
|
||||
token="valid_token",
|
||||
client_id="test_client",
|
||||
scopes=["read", "write"],
|
||||
expires_at=int(time.time()) + 3600, # 1 hour from now
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_auth_context_middleware_with_authenticated_user(valid_access_token: AccessToken):
|
||||
"""Test middleware with an authenticated user in scope."""
|
||||
app = MockApp()
|
||||
middleware = AuthContextMiddleware(app)
|
||||
|
||||
# Create an authenticated user
|
||||
user = AuthenticatedUser(valid_access_token)
|
||||
|
||||
scope: Scope = {"type": "http", "user": user}
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message: # pragma: no cover
|
||||
return {"type": "http.request"}
|
||||
|
||||
async def send(message: Message) -> None: # pragma: no cover
|
||||
pass
|
||||
|
||||
# Verify context is empty before middleware
|
||||
assert auth_context_var.get() is None
|
||||
assert get_access_token() is None
|
||||
|
||||
# Run the middleware
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
# Verify the app was called
|
||||
assert app.called
|
||||
assert app.scope == scope
|
||||
assert app.receive == receive
|
||||
assert app.send == send
|
||||
|
||||
# Verify the access token was available during the call
|
||||
assert app.access_token_during_call == valid_access_token
|
||||
|
||||
# Verify context is reset after middleware
|
||||
assert auth_context_var.get() is None
|
||||
assert get_access_token() is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_auth_context_middleware_with_no_user():
|
||||
"""Test middleware with no user in scope."""
|
||||
app = MockApp()
|
||||
middleware = AuthContextMiddleware(app)
|
||||
|
||||
scope: Scope = {"type": "http"} # No user
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message: # pragma: no cover
|
||||
return {"type": "http.request"}
|
||||
|
||||
async def send(message: Message) -> None: # pragma: no cover
|
||||
pass
|
||||
|
||||
# Verify context is empty before middleware
|
||||
assert auth_context_var.get() is None
|
||||
assert get_access_token() is None
|
||||
|
||||
# Run the middleware
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
# Verify the app was called
|
||||
assert app.called
|
||||
assert app.scope == scope
|
||||
assert app.receive == receive
|
||||
assert app.send == send
|
||||
|
||||
# Verify the access token was not available during the call
|
||||
assert app.access_token_during_call is None
|
||||
|
||||
# Verify context is still empty after middleware
|
||||
assert auth_context_var.get() is None
|
||||
assert get_access_token() is None
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Tests for the BearerAuth middleware components."""
|
||||
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from starlette.authentication import AuthCredentials
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.requests import Request
|
||||
from starlette.types import Message, Receive, Scope, Send
|
||||
|
||||
from mcp.server.auth.middleware.bearer_auth import (
|
||||
AuthenticatedUser,
|
||||
BearerAuthBackend,
|
||||
RequireAuthMiddleware,
|
||||
authorization_context,
|
||||
)
|
||||
from mcp.server.auth.provider import (
|
||||
AccessToken,
|
||||
OAuthAuthorizationServerProvider,
|
||||
ProviderTokenVerifier,
|
||||
principal_components,
|
||||
)
|
||||
|
||||
|
||||
class MockOAuthProvider:
|
||||
"""Mock OAuth provider for testing.
|
||||
|
||||
This is a simplified version that only implements the methods needed for testing
|
||||
the BearerAuthMiddleware components.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.tokens: dict[str, AccessToken] = {} # token -> AccessToken
|
||||
|
||||
def add_token(self, token: str, access_token: AccessToken) -> None:
|
||||
"""Add a token to the provider."""
|
||||
self.tokens[token] = access_token
|
||||
|
||||
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||
"""Load an access token."""
|
||||
return self.tokens.get(token)
|
||||
|
||||
|
||||
def add_token_to_provider(
|
||||
provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
||||
token: str,
|
||||
access_token: AccessToken,
|
||||
) -> None:
|
||||
"""Helper function to add a token to a provider.
|
||||
|
||||
This is used to work around type checking issues with our mock provider.
|
||||
"""
|
||||
# We know this is actually a MockOAuthProvider
|
||||
mock_provider = cast(MockOAuthProvider, provider)
|
||||
mock_provider.add_token(token, access_token)
|
||||
|
||||
|
||||
class MockApp:
|
||||
"""Mock ASGI app for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
self.scope: Scope | None = None
|
||||
self.receive: Receive | None = None
|
||||
self.send: Send | None = None
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
self.called = True
|
||||
self.scope = scope
|
||||
self.receive = receive
|
||||
self.send = send
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_oauth_provider() -> OAuthAuthorizationServerProvider[Any, Any, Any]:
|
||||
"""Create a mock OAuth provider."""
|
||||
# Use type casting to satisfy the type checker
|
||||
return cast(OAuthAuthorizationServerProvider[Any, Any, Any], MockOAuthProvider())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_access_token() -> AccessToken:
|
||||
"""Create a valid access token."""
|
||||
return AccessToken(
|
||||
token="valid_token",
|
||||
client_id="test_client",
|
||||
scopes=["read", "write"],
|
||||
expires_at=int(time.time()) + 3600, # 1 hour from now
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expired_access_token() -> AccessToken:
|
||||
"""Create an expired access token."""
|
||||
return AccessToken(
|
||||
token="expired_token",
|
||||
client_id="test_client",
|
||||
scopes=["read"],
|
||||
expires_at=int(time.time()) - 3600, # 1 hour ago
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_expiry_access_token() -> AccessToken:
|
||||
"""Create an access token with no expiry."""
|
||||
return AccessToken(
|
||||
token="no_expiry_token",
|
||||
client_id="test_client",
|
||||
scopes=["read", "write"],
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
class TestBearerAuthBackend:
|
||||
"""Tests for the BearerAuthBackend class."""
|
||||
|
||||
async def test_no_auth_header(self, mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any]):
|
||||
"""Test authentication with no Authorization header."""
|
||||
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
|
||||
request = Request({"type": "http", "headers": []})
|
||||
result = await backend.authenticate(request)
|
||||
assert result is None
|
||||
|
||||
async def test_non_bearer_auth_header(self, mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any]):
|
||||
"""Test authentication with non-Bearer Authorization header."""
|
||||
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"authorization", b"Basic dXNlcjpwYXNz")],
|
||||
}
|
||||
)
|
||||
result = await backend.authenticate(request)
|
||||
assert result is None
|
||||
|
||||
async def test_invalid_token(self, mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any]):
|
||||
"""Test authentication with invalid token."""
|
||||
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"authorization", b"Bearer invalid_token")],
|
||||
}
|
||||
)
|
||||
result = await backend.authenticate(request)
|
||||
assert result is None
|
||||
|
||||
async def test_expired_token(
|
||||
self,
|
||||
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
||||
expired_access_token: AccessToken,
|
||||
):
|
||||
"""Test authentication with expired token."""
|
||||
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
|
||||
add_token_to_provider(mock_oauth_provider, "expired_token", expired_access_token)
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"authorization", b"Bearer expired_token")],
|
||||
}
|
||||
)
|
||||
result = await backend.authenticate(request)
|
||||
assert result is None
|
||||
|
||||
async def test_valid_token(
|
||||
self,
|
||||
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
||||
valid_access_token: AccessToken,
|
||||
):
|
||||
"""Test authentication with valid token."""
|
||||
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
|
||||
add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token)
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"authorization", b"Bearer valid_token")],
|
||||
}
|
||||
)
|
||||
result = await backend.authenticate(request)
|
||||
assert result is not None
|
||||
credentials, user = result
|
||||
assert isinstance(credentials, AuthCredentials)
|
||||
assert isinstance(user, AuthenticatedUser)
|
||||
assert credentials.scopes == ["read", "write"]
|
||||
assert user.display_name == "test_client"
|
||||
assert user.access_token == valid_access_token
|
||||
assert user.scopes == ["read", "write"]
|
||||
|
||||
async def test_token_without_expiry(
|
||||
self,
|
||||
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
||||
no_expiry_access_token: AccessToken,
|
||||
):
|
||||
"""Test authentication with token that has no expiry."""
|
||||
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
|
||||
add_token_to_provider(mock_oauth_provider, "no_expiry_token", no_expiry_access_token)
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"authorization", b"Bearer no_expiry_token")],
|
||||
}
|
||||
)
|
||||
result = await backend.authenticate(request)
|
||||
assert result is not None
|
||||
credentials, user = result
|
||||
assert isinstance(credentials, AuthCredentials)
|
||||
assert isinstance(user, AuthenticatedUser)
|
||||
assert credentials.scopes == ["read", "write"]
|
||||
assert user.display_name == "test_client"
|
||||
assert user.access_token == no_expiry_access_token
|
||||
assert user.scopes == ["read", "write"]
|
||||
|
||||
async def test_lowercase_bearer_prefix(
|
||||
self,
|
||||
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
||||
valid_access_token: AccessToken,
|
||||
):
|
||||
"""Test with lowercase 'bearer' prefix in Authorization header"""
|
||||
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
|
||||
add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token)
|
||||
headers = Headers({"Authorization": "bearer valid_token"})
|
||||
scope = {"type": "http", "headers": headers.raw}
|
||||
request = Request(scope)
|
||||
result = await backend.authenticate(request)
|
||||
assert result is not None
|
||||
credentials, user = result
|
||||
assert isinstance(credentials, AuthCredentials)
|
||||
assert isinstance(user, AuthenticatedUser)
|
||||
assert credentials.scopes == ["read", "write"]
|
||||
assert user.display_name == "test_client"
|
||||
assert user.access_token == valid_access_token
|
||||
|
||||
async def test_mixed_case_bearer_prefix(
|
||||
self,
|
||||
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
||||
valid_access_token: AccessToken,
|
||||
):
|
||||
"""Test with mixed 'BeArEr' prefix in Authorization header"""
|
||||
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
|
||||
add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token)
|
||||
headers = Headers({"authorization": "BeArEr valid_token"})
|
||||
scope = {"type": "http", "headers": headers.raw}
|
||||
request = Request(scope)
|
||||
result = await backend.authenticate(request)
|
||||
assert result is not None
|
||||
credentials, user = result
|
||||
assert isinstance(credentials, AuthCredentials)
|
||||
assert isinstance(user, AuthenticatedUser)
|
||||
assert credentials.scopes == ["read", "write"]
|
||||
assert user.display_name == "test_client"
|
||||
assert user.access_token == valid_access_token
|
||||
|
||||
async def test_mixed_case_authorization_header(
|
||||
self,
|
||||
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
||||
valid_access_token: AccessToken,
|
||||
):
|
||||
"""Test authentication with mixed 'Authorization' header."""
|
||||
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
|
||||
add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token)
|
||||
headers = Headers({"AuThOrIzAtIoN": "BeArEr valid_token"})
|
||||
scope = {"type": "http", "headers": headers.raw}
|
||||
request = Request(scope)
|
||||
result = await backend.authenticate(request)
|
||||
assert result is not None
|
||||
credentials, user = result
|
||||
assert isinstance(credentials, AuthCredentials)
|
||||
assert isinstance(user, AuthenticatedUser)
|
||||
assert credentials.scopes == ["read", "write"]
|
||||
assert user.display_name == "test_client"
|
||||
assert user.access_token == valid_access_token
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
class TestRequireAuthMiddleware:
|
||||
"""Tests for the RequireAuthMiddleware class."""
|
||||
|
||||
async def test_no_user(self):
|
||||
"""Test middleware with no user in scope."""
|
||||
app = MockApp()
|
||||
middleware = RequireAuthMiddleware(app, required_scopes=["read"])
|
||||
scope: Scope = {"type": "http"}
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message: # pragma: no cover
|
||||
return {"type": "http.request"}
|
||||
|
||||
sent_messages: list[Message] = []
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent_messages.append(message)
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
# Check that a 401 response was sent
|
||||
assert len(sent_messages) == 2
|
||||
assert sent_messages[0]["type"] == "http.response.start"
|
||||
assert sent_messages[0]["status"] == 401
|
||||
assert any(h[0] == b"www-authenticate" for h in sent_messages[0]["headers"])
|
||||
assert not app.called
|
||||
|
||||
async def test_non_authenticated_user(self):
|
||||
"""Test middleware with non-authenticated user in scope."""
|
||||
app = MockApp()
|
||||
middleware = RequireAuthMiddleware(app, required_scopes=["read"])
|
||||
scope: Scope = {"type": "http", "user": object()}
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message: # pragma: no cover
|
||||
return {"type": "http.request"}
|
||||
|
||||
sent_messages: list[Message] = []
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent_messages.append(message)
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
# Check that a 401 response was sent
|
||||
assert len(sent_messages) == 2
|
||||
assert sent_messages[0]["type"] == "http.response.start"
|
||||
assert sent_messages[0]["status"] == 401
|
||||
assert any(h[0] == b"www-authenticate" for h in sent_messages[0]["headers"])
|
||||
assert not app.called
|
||||
|
||||
async def test_missing_required_scope(self, valid_access_token: AccessToken):
|
||||
"""Test middleware with user missing required scope."""
|
||||
app = MockApp()
|
||||
middleware = RequireAuthMiddleware(app, required_scopes=["admin"])
|
||||
|
||||
# Create a user with read/write scopes but not admin
|
||||
user = AuthenticatedUser(valid_access_token)
|
||||
auth = AuthCredentials(["read", "write"])
|
||||
|
||||
scope: Scope = {"type": "http", "user": user, "auth": auth}
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message: # pragma: no cover
|
||||
return {"type": "http.request"}
|
||||
|
||||
sent_messages: list[Message] = []
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent_messages.append(message)
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
# Check that a 403 response was sent
|
||||
assert len(sent_messages) == 2
|
||||
assert sent_messages[0]["type"] == "http.response.start"
|
||||
assert sent_messages[0]["status"] == 403
|
||||
assert any(h[0] == b"www-authenticate" for h in sent_messages[0]["headers"])
|
||||
assert not app.called
|
||||
|
||||
async def test_no_auth_credentials(self, valid_access_token: AccessToken):
|
||||
"""Test middleware with no auth credentials in scope."""
|
||||
app = MockApp()
|
||||
middleware = RequireAuthMiddleware(app, required_scopes=["read"])
|
||||
|
||||
# Create a user with read/write scopes
|
||||
user = AuthenticatedUser(valid_access_token)
|
||||
|
||||
scope: Scope = {"type": "http", "user": user} # No auth credentials
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message: # pragma: no cover
|
||||
return {"type": "http.request"}
|
||||
|
||||
sent_messages: list[Message] = []
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent_messages.append(message)
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
# Check that a 403 response was sent
|
||||
assert len(sent_messages) == 2
|
||||
assert sent_messages[0]["type"] == "http.response.start"
|
||||
assert sent_messages[0]["status"] == 403
|
||||
assert any(h[0] == b"www-authenticate" for h in sent_messages[0]["headers"])
|
||||
assert not app.called
|
||||
|
||||
async def test_has_required_scopes(self, valid_access_token: AccessToken):
|
||||
"""Test middleware with user having all required scopes."""
|
||||
app = MockApp()
|
||||
middleware = RequireAuthMiddleware(app, required_scopes=["read"])
|
||||
|
||||
# Create a user with read/write scopes
|
||||
user = AuthenticatedUser(valid_access_token)
|
||||
auth = AuthCredentials(["read", "write"])
|
||||
|
||||
scope: Scope = {"type": "http", "user": user, "auth": auth}
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message: # pragma: no cover
|
||||
return {"type": "http.request"}
|
||||
|
||||
async def send(message: Message) -> None: # pragma: no cover
|
||||
pass
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
assert app.called
|
||||
assert app.scope == scope
|
||||
assert app.receive == receive
|
||||
assert app.send == send
|
||||
|
||||
async def test_multiple_required_scopes(self, valid_access_token: AccessToken):
|
||||
"""Test middleware with multiple required scopes."""
|
||||
app = MockApp()
|
||||
middleware = RequireAuthMiddleware(app, required_scopes=["read", "write"])
|
||||
|
||||
# Create a user with read/write scopes
|
||||
user = AuthenticatedUser(valid_access_token)
|
||||
auth = AuthCredentials(["read", "write"])
|
||||
|
||||
scope: Scope = {"type": "http", "user": user, "auth": auth}
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message: # pragma: no cover
|
||||
return {"type": "http.request"}
|
||||
|
||||
async def send(message: Message) -> None: # pragma: no cover
|
||||
pass
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
assert app.called
|
||||
assert app.scope == scope
|
||||
assert app.receive == receive
|
||||
assert app.send == send
|
||||
|
||||
async def test_no_required_scopes(self, valid_access_token: AccessToken):
|
||||
"""Test middleware with no required scopes."""
|
||||
app = MockApp()
|
||||
middleware = RequireAuthMiddleware(app, required_scopes=[])
|
||||
|
||||
# Create a user with read/write scopes
|
||||
user = AuthenticatedUser(valid_access_token)
|
||||
auth = AuthCredentials(["read", "write"])
|
||||
|
||||
scope: Scope = {"type": "http", "user": user, "auth": auth}
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message: # pragma: no cover
|
||||
return {"type": "http.request"}
|
||||
|
||||
async def send(message: Message) -> None: # pragma: no cover
|
||||
pass
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
assert app.called
|
||||
assert app.scope == scope
|
||||
assert app.receive == receive
|
||||
assert app.send == send
|
||||
|
||||
|
||||
def test_authorization_context_is_built_from_principal_components() -> None:
|
||||
"""Session ownership identifies the principal via the shared principal_components triple."""
|
||||
token = AccessToken(
|
||||
token="t", client_id="client-1", scopes=[], subject="alice", claims={"iss": "https://as.example"}
|
||||
)
|
||||
client_id, issuer, subject = principal_components(token)
|
||||
assert authorization_context(AuthenticatedUser(token)) == {
|
||||
"client_id": client_id,
|
||||
"issuer": issuer,
|
||||
"subject": subject,
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Tests for OAuth error handling in the auth handlers."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
import unittest.mock
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import ASGITransport
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.auth.provider import AuthorizeError, RegistrationError, TokenError
|
||||
from mcp.server.auth.routes import create_auth_routes
|
||||
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
|
||||
from tests.server.mcpserver.auth.test_auth_integration import MockOAuthProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_provider():
|
||||
"""Return a MockOAuthProvider instance that can be configured to raise errors."""
|
||||
return MockOAuthProvider()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(oauth_provider: MockOAuthProvider):
|
||||
# Enable client registration
|
||||
client_registration_options = ClientRegistrationOptions(enabled=True)
|
||||
revocation_options = RevocationOptions(enabled=True)
|
||||
|
||||
# Create auth routes
|
||||
auth_routes = create_auth_routes(
|
||||
oauth_provider,
|
||||
issuer_url=AnyHttpUrl("http://localhost"),
|
||||
client_registration_options=client_registration_options,
|
||||
revocation_options=revocation_options,
|
||||
)
|
||||
|
||||
# Create Starlette app with routes directly
|
||||
return Starlette(routes=auth_routes)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app: Starlette):
|
||||
transport = ASGITransport(app=app)
|
||||
# Use base_url without a path since routes are directly on the app
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://localhost")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pkce_challenge():
|
||||
"""Create a PKCE challenge with code_verifier and code_challenge."""
|
||||
# Generate a code verifier
|
||||
code_verifier = secrets.token_urlsafe(64)[:128]
|
||||
|
||||
# Create code challenge using S256 method
|
||||
code_verifier_bytes = code_verifier.encode("ascii")
|
||||
sha256 = hashlib.sha256(code_verifier_bytes).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(sha256).decode().rstrip("=")
|
||||
|
||||
return {"code_verifier": code_verifier, "code_challenge": code_challenge}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def registered_client(client: httpx.AsyncClient) -> dict[str, Any]:
|
||||
"""Create and register a test client."""
|
||||
# Default client metadata
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"client_name": "Test Client",
|
||||
}
|
||||
|
||||
response = await client.post("/register", json=client_metadata)
|
||||
assert response.status_code == 201, f"Failed to register client: {response.content}"
|
||||
|
||||
client_info = response.json()
|
||||
return client_info
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_registration_error_handling(client: httpx.AsyncClient, oauth_provider: MockOAuthProvider):
|
||||
# Mock the register_client method to raise a registration error
|
||||
with unittest.mock.patch.object(
|
||||
oauth_provider,
|
||||
"register_client",
|
||||
side_effect=RegistrationError(
|
||||
error="invalid_redirect_uri",
|
||||
error_description="The redirect URI is invalid",
|
||||
),
|
||||
):
|
||||
# Prepare a client registration request
|
||||
client_data = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"client_name": "Test Client",
|
||||
}
|
||||
|
||||
# Send the registration request
|
||||
response = await client.post(
|
||||
"/register",
|
||||
json=client_data,
|
||||
)
|
||||
|
||||
# Verify the response
|
||||
assert response.status_code == 400, response.content
|
||||
data = response.json()
|
||||
assert data["error"] == "invalid_redirect_uri"
|
||||
assert data["error_description"] == "The redirect URI is invalid"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_error_handling(
|
||||
client: httpx.AsyncClient,
|
||||
oauth_provider: MockOAuthProvider,
|
||||
registered_client: dict[str, Any],
|
||||
pkce_challenge: dict[str, str],
|
||||
):
|
||||
# Mock the authorize method to raise an authorize error
|
||||
with unittest.mock.patch.object(
|
||||
oauth_provider,
|
||||
"authorize",
|
||||
side_effect=AuthorizeError(error="access_denied", error_description="The user denied the request"),
|
||||
):
|
||||
# Register the client
|
||||
client_id = registered_client["client_id"]
|
||||
redirect_uri = registered_client["redirect_uris"][0]
|
||||
|
||||
# Prepare an authorization request
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"code_challenge": pkce_challenge["code_challenge"],
|
||||
"code_challenge_method": "S256",
|
||||
"state": "test_state",
|
||||
}
|
||||
|
||||
# Send the authorization request
|
||||
response = await client.get("/authorize", params=params)
|
||||
|
||||
# Verify the response is a redirect with error parameters
|
||||
assert response.status_code == 302
|
||||
redirect_url = response.headers["location"]
|
||||
parsed_url = urlparse(redirect_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
|
||||
assert query_params["error"][0] == "access_denied"
|
||||
assert "error_description" in query_params
|
||||
assert query_params["state"][0] == "test_state"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_error_handling_auth_code(
|
||||
client: httpx.AsyncClient,
|
||||
oauth_provider: MockOAuthProvider,
|
||||
registered_client: dict[str, Any],
|
||||
pkce_challenge: dict[str, str],
|
||||
):
|
||||
# Register the client and get an auth code
|
||||
client_id = registered_client["client_id"]
|
||||
client_secret = registered_client["client_secret"]
|
||||
redirect_uri = registered_client["redirect_uris"][0]
|
||||
|
||||
# First get an authorization code
|
||||
auth_response = await client.get(
|
||||
"/authorize",
|
||||
params={
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"code_challenge": pkce_challenge["code_challenge"],
|
||||
"code_challenge_method": "S256",
|
||||
"state": "test_state",
|
||||
},
|
||||
)
|
||||
|
||||
redirect_url = auth_response.headers["location"]
|
||||
parsed_url = urlparse(redirect_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
code = query_params["code"][0]
|
||||
|
||||
# Mock the exchange_authorization_code method to raise a token error
|
||||
with unittest.mock.patch.object(
|
||||
oauth_provider,
|
||||
"exchange_authorization_code",
|
||||
side_effect=TokenError(
|
||||
error="invalid_grant",
|
||||
error_description="The authorization code is invalid",
|
||||
),
|
||||
):
|
||||
# Try to exchange the code for tokens
|
||||
token_response = await client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code_verifier": pkce_challenge["code_verifier"],
|
||||
},
|
||||
)
|
||||
|
||||
# Verify the response
|
||||
assert token_response.status_code == 400
|
||||
data = token_response.json()
|
||||
assert data["error"] == "invalid_grant"
|
||||
assert data["error_description"] == "The authorization code is invalid"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_error_handling_refresh_token(
|
||||
client: httpx.AsyncClient,
|
||||
oauth_provider: MockOAuthProvider,
|
||||
registered_client: dict[str, Any],
|
||||
pkce_challenge: dict[str, str],
|
||||
):
|
||||
# Register the client and get tokens
|
||||
client_id = registered_client["client_id"]
|
||||
client_secret = registered_client["client_secret"]
|
||||
redirect_uri = registered_client["redirect_uris"][0]
|
||||
|
||||
# First get an authorization code
|
||||
auth_response = await client.get(
|
||||
"/authorize",
|
||||
params={
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"code_challenge": pkce_challenge["code_challenge"],
|
||||
"code_challenge_method": "S256",
|
||||
"state": "test_state",
|
||||
},
|
||||
)
|
||||
assert auth_response.status_code == 302, auth_response.content
|
||||
|
||||
redirect_url = auth_response.headers["location"]
|
||||
parsed_url = urlparse(redirect_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
code = query_params["code"][0]
|
||||
|
||||
# Exchange the code for tokens
|
||||
token_response = await client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code_verifier": pkce_challenge["code_verifier"],
|
||||
},
|
||||
)
|
||||
|
||||
tokens = token_response.json()
|
||||
refresh_token = tokens["refresh_token"]
|
||||
|
||||
# Mock the exchange_refresh_token method to raise a token error
|
||||
with unittest.mock.patch.object(
|
||||
oauth_provider,
|
||||
"exchange_refresh_token",
|
||||
side_effect=TokenError(
|
||||
error="invalid_scope",
|
||||
error_description="The requested scope is invalid",
|
||||
),
|
||||
):
|
||||
# Try to use the refresh token
|
||||
refresh_response = await client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
# Verify the response
|
||||
assert refresh_response.status_code == 400
|
||||
data = refresh_response.json()
|
||||
assert data["error"] == "invalid_scope"
|
||||
assert data["error_description"] == "The requested scope is invalid"
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Server-side SEP-990 Identity Assertion Authorization Grant (RFC 7523 jwt-bearer) handling."""
|
||||
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import ASGITransport
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.auth.provider import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
AuthorizationParams,
|
||||
IdentityAssertionParams,
|
||||
OAuthAuthorizationServerProvider,
|
||||
RefreshToken,
|
||||
TokenError,
|
||||
)
|
||||
from mcp.server.auth.routes import build_metadata, create_auth_routes
|
||||
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
|
||||
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
|
||||
|
||||
ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"
|
||||
VALID_ASSERTION = "valid-id-jag"
|
||||
CONFIDENTIAL_CLIENT_ID = "enterprise-client"
|
||||
CONFIDENTIAL_CLIENT_SECRET = "enterprise-secret"
|
||||
|
||||
|
||||
class IdentityAssertionProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
|
||||
"""A provider that implements `exchange_identity_assertion`; everything else is unused here."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.clients: dict[str, OAuthClientInformationFull] = {}
|
||||
self.tokens: dict[str, AccessToken] = {}
|
||||
self.last_params: IdentityAssertionParams | None = None
|
||||
|
||||
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||
return self.clients.get(client_id)
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
assert client_info.client_id is not None
|
||||
self.clients[client_info.client_id] = client_info
|
||||
|
||||
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> AuthorizationCode | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
||||
) -> OAuthToken:
|
||||
raise NotImplementedError
|
||||
|
||||
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
|
||||
) -> OAuthToken:
|
||||
raise NotImplementedError
|
||||
|
||||
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||
return self.tokens.get(token)
|
||||
|
||||
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exchange_identity_assertion(
|
||||
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
|
||||
) -> OAuthToken:
|
||||
self.last_params = params
|
||||
# Stand-in for RFC 7523 §3 / SEP-990 §5.1 assertion validation.
|
||||
if params.assertion != VALID_ASSERTION:
|
||||
raise TokenError(error="invalid_grant", error_description="assertion is not valid")
|
||||
assert client.client_id is not None
|
||||
scopes = params.scopes or ["mcp"]
|
||||
access = f"access_{secrets.token_hex(16)}"
|
||||
self.tokens[access] = AccessToken(
|
||||
token=access,
|
||||
client_id=client.client_id,
|
||||
scopes=scopes,
|
||||
expires_at=int(time.time()) + 3600,
|
||||
resource=params.resource,
|
||||
subject="assertion-user",
|
||||
)
|
||||
return OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider() -> IdentityAssertionProvider:
|
||||
prov = IdentityAssertionProvider()
|
||||
# Pre-register a confidential client (DCR refuses the grant; see the DCR test).
|
||||
prov.clients[CONFIDENTIAL_CLIENT_ID] = OAuthClientInformationFull(
|
||||
client_id=CONFIDENTIAL_CLIENT_ID,
|
||||
client_secret=CONFIDENTIAL_CLIENT_SECRET,
|
||||
redirect_uris=None,
|
||||
grant_types=[JWT_BEARER_GRANT_TYPE],
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
scope="mcp",
|
||||
)
|
||||
return prov
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(provider: IdentityAssertionProvider) -> Starlette:
|
||||
routes = create_auth_routes(
|
||||
provider,
|
||||
issuer_url=AnyHttpUrl("https://auth.example.com"),
|
||||
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=["mcp"]),
|
||||
revocation_options=RevocationOptions(enabled=False),
|
||||
identity_assertion_enabled=True,
|
||||
)
|
||||
return Starlette(routes=routes)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app: Starlette):
|
||||
transport = ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http:
|
||||
yield http
|
||||
|
||||
|
||||
def assertion_form(**overrides: str) -> dict[str, str]:
|
||||
form = {
|
||||
"grant_type": JWT_BEARER_GRANT_TYPE,
|
||||
"client_id": CONFIDENTIAL_CLIENT_ID,
|
||||
"client_secret": CONFIDENTIAL_CLIENT_SECRET,
|
||||
"assertion": VALID_ASSERTION,
|
||||
}
|
||||
form.update(overrides)
|
||||
return form
|
||||
|
||||
|
||||
def test_build_metadata_advertises_id_jag_profile_when_enabled():
|
||||
enabled = build_metadata(
|
||||
AnyHttpUrl("https://auth.example.com"),
|
||||
None,
|
||||
ClientRegistrationOptions(),
|
||||
RevocationOptions(),
|
||||
supports_identity_assertion=True,
|
||||
)
|
||||
assert JWT_BEARER_GRANT_TYPE in (enabled.grant_types_supported or [])
|
||||
assert enabled.authorization_grant_profiles_supported == [ID_JAG_GRANT_PROFILE]
|
||||
# The grant is confidential-only, so the `none` auth method is NOT advertised.
|
||||
assert "none" not in (enabled.token_endpoint_auth_methods_supported or [])
|
||||
|
||||
disabled = build_metadata(
|
||||
AnyHttpUrl("https://auth.example.com"),
|
||||
None,
|
||||
ClientRegistrationOptions(),
|
||||
RevocationOptions(),
|
||||
)
|
||||
assert JWT_BEARER_GRANT_TYPE not in (disabled.grant_types_supported or [])
|
||||
assert disabled.authorization_grant_profiles_supported is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_metadata_endpoint_lists_id_jag_profile(client: httpx.AsyncClient):
|
||||
response = await client.get("/.well-known/oauth-authorization-server")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert JWT_BEARER_GRANT_TYPE in body["grant_types_supported"]
|
||||
assert body["authorization_grant_profiles_supported"] == [ID_JAG_GRANT_PROFILE]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_assertion_success(client: httpx.AsyncClient, provider: IdentityAssertionProvider):
|
||||
response = await client.post("/token", data=assertion_form(scope="mcp", resource="https://mcp.example.com/mcp"))
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
body = response.json()
|
||||
assert body["token_type"] == "Bearer"
|
||||
assert "issued_token_type" not in body # plain RFC 6749 response under jwt-bearer
|
||||
|
||||
issued = await provider.load_access_token(body["access_token"])
|
||||
assert issued is not None
|
||||
assert issued.scopes == ["mcp"]
|
||||
assert issued.subject == "assertion-user"
|
||||
|
||||
assert provider.last_params is not None
|
||||
assert provider.last_params.assertion == VALID_ASSERTION
|
||||
assert provider.last_params.scopes == ["mcp"]
|
||||
assert provider.last_params.resource == "https://mcp.example.com/mcp"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_assertion_invalid_assertion(client: httpx.AsyncClient):
|
||||
response = await client.post("/token", data=assertion_form(assertion="forged"))
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"error": "invalid_grant", "error_description": "assertion is not valid"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_assertion_rejected_when_disabled(provider: IdentityAssertionProvider):
|
||||
routes = create_auth_routes(
|
||||
provider,
|
||||
issuer_url=AnyHttpUrl("https://auth.example.com"),
|
||||
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=["mcp"]),
|
||||
revocation_options=RevocationOptions(enabled=False),
|
||||
identity_assertion_enabled=False,
|
||||
)
|
||||
app = Starlette(routes=routes)
|
||||
transport = ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http:
|
||||
response = await http.post("/token", data=assertion_form())
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "unsupported_grant_type"
|
||||
assert provider.last_params is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_assertion_rejects_public_client(client: httpx.AsyncClient, provider: IdentityAssertionProvider):
|
||||
"""A public (auth method 'none') client cannot use the grant, even if it presents a valid assertion."""
|
||||
provider.clients["public-client"] = OAuthClientInformationFull(
|
||||
client_id="public-client",
|
||||
redirect_uris=None,
|
||||
grant_types=[JWT_BEARER_GRANT_TYPE],
|
||||
token_endpoint_auth_method="none",
|
||||
scope="mcp",
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
"/token",
|
||||
data={"grant_type": JWT_BEARER_GRANT_TYPE, "client_id": "public-client", "assertion": VALID_ASSERTION},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "unauthorized_client"
|
||||
assert provider.last_params is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_assertion_rejects_secretless_confidential_client(
|
||||
client: httpx.AsyncClient, provider: IdentityAssertionProvider
|
||||
):
|
||||
"""A client registered with a secret-based method but no stored secret fails authentication.
|
||||
|
||||
`ClientAuthenticator` rejects this misconfiguration as `invalid_client`, so the request never
|
||||
reaches the jwt-bearer handler or the provider hook.
|
||||
"""
|
||||
provider.clients["secretless-client"] = OAuthClientInformationFull(
|
||||
client_id="secretless-client",
|
||||
client_secret=None,
|
||||
redirect_uris=None,
|
||||
grant_types=[JWT_BEARER_GRANT_TYPE],
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
scope="mcp",
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
"/token",
|
||||
data={"grant_type": JWT_BEARER_GRANT_TYPE, "client_id": "secretless-client", "assertion": VALID_ASSERTION},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
body = response.json()
|
||||
assert body["error"] == "invalid_client"
|
||||
assert "no stored secret" in body["error_description"]
|
||||
assert provider.last_params is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_malformed_request_missing_assertion_is_invalid_request(client: httpx.AsyncClient):
|
||||
"""A jwt-bearer request without the required `assertion` fails validation with invalid_request."""
|
||||
response = await client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": JWT_BEARER_GRANT_TYPE,
|
||||
"client_id": CONFIDENTIAL_CLIENT_ID,
|
||||
"client_secret": CONFIDENTIAL_CLIENT_SECRET,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "invalid_request"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_without_the_grant_registered_is_rejected(
|
||||
client: httpx.AsyncClient, provider: IdentityAssertionProvider
|
||||
):
|
||||
"""A confidential client whose registration omits the jwt-bearer grant is refused the grant."""
|
||||
provider.clients["no-grant-client"] = OAuthClientInformationFull(
|
||||
client_id="no-grant-client",
|
||||
client_secret="s",
|
||||
redirect_uris=None,
|
||||
grant_types=["authorization_code"],
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
scope="mcp",
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": JWT_BEARER_GRANT_TYPE,
|
||||
"client_id": "no-grant-client",
|
||||
"client_secret": "s",
|
||||
"assertion": VALID_ASSERTION,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "unsupported_grant_type"
|
||||
assert provider.last_params is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dcr_refuses_to_register_the_jwt_bearer_grant(
|
||||
client: httpx.AsyncClient, provider: IdentityAssertionProvider
|
||||
):
|
||||
"""Dynamic client registration rejects the jwt-bearer grant; the ID-JAG flow needs pre-registration."""
|
||||
response = await client.post(
|
||||
"/register",
|
||||
json={
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code", JWT_BEARER_GRANT_TYPE],
|
||||
"response_types": ["code"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
body = response.json()
|
||||
assert body["error"] == "invalid_client_metadata"
|
||||
assert JWT_BEARER_GRANT_TYPE in body["error_description"]
|
||||
|
||||
# A registration without the jwt-bearer grant still succeeds and is stored.
|
||||
ok = await client.post(
|
||||
"/register",
|
||||
json={
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code"],
|
||||
"response_types": ["code"],
|
||||
},
|
||||
)
|
||||
assert ok.status_code == 201
|
||||
assert ok.json()["client_id"] in provider.clients
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_default_provider_rejects_identity_assertion():
|
||||
"""A provider that does not override `exchange_identity_assertion` rejects with unsupported_grant_type."""
|
||||
|
||||
class BareProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
|
||||
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> AuthorizationCode | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
||||
) -> OAuthToken:
|
||||
raise NotImplementedError
|
||||
|
||||
async def load_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: str
|
||||
) -> RefreshToken | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
|
||||
) -> OAuthToken:
|
||||
raise NotImplementedError
|
||||
|
||||
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
bare = BareProvider()
|
||||
client_info = OAuthClientInformationFull(
|
||||
redirect_uris=None,
|
||||
client_id="c",
|
||||
grant_types=[JWT_BEARER_GRANT_TYPE],
|
||||
)
|
||||
params = IdentityAssertionParams(assertion=VALID_ASSERTION)
|
||||
with pytest.raises(TokenError) as excinfo:
|
||||
await bare.exchange_identity_assertion(client_info, params)
|
||||
assert excinfo.value.error == "unsupported_grant_type"
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Integration tests for MCP Oauth Protected Resource."""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.auth.routes import build_resource_metadata_url, create_protected_resource_routes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_app():
|
||||
"""Fixture to create protected resource routes for testing."""
|
||||
|
||||
# Create the protected resource routes
|
||||
protected_resource_routes = create_protected_resource_routes(
|
||||
resource_url=AnyHttpUrl("https://example.com/resource"),
|
||||
authorization_servers=[AnyHttpUrl("https://auth.example.com/authorization")],
|
||||
scopes_supported=["read", "write"],
|
||||
resource_name="Example Resource",
|
||||
resource_documentation=AnyHttpUrl("https://docs.example.com/resource"),
|
||||
)
|
||||
|
||||
app = Starlette(routes=protected_resource_routes)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_client(test_app: Starlette):
|
||||
"""Fixture to create an HTTP client for the protected resource app."""
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=test_app), base_url="https://mcptest.com") as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_metadata_endpoint_with_path(test_client: httpx.AsyncClient):
|
||||
"""Test the OAuth 2.0 Protected Resource metadata endpoint for path-based resource."""
|
||||
|
||||
# For resource with path "/resource", metadata should be accessible at the path-aware location
|
||||
response = await test_client.get("/.well-known/oauth-protected-resource/resource")
|
||||
assert response.json() == snapshot(
|
||||
{
|
||||
"resource": "https://example.com/resource",
|
||||
"authorization_servers": ["https://auth.example.com/authorization"],
|
||||
"scopes_supported": ["read", "write"],
|
||||
"resource_name": "Example Resource",
|
||||
"resource_documentation": "https://docs.example.com/resource",
|
||||
"bearer_methods_supported": ["header"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_metadata_endpoint_root_path_returns_404(test_client: httpx.AsyncClient):
|
||||
"""Test that root path returns 404 for path-based resource."""
|
||||
|
||||
# Root path should return 404 for path-based resources
|
||||
response = await test_client.get("/.well-known/oauth-protected-resource")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root_resource_app():
|
||||
"""Fixture to create protected resource routes for root-level resource."""
|
||||
|
||||
# Create routes for a resource without path component
|
||||
protected_resource_routes = create_protected_resource_routes(
|
||||
resource_url=AnyHttpUrl("https://example.com"),
|
||||
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
||||
scopes_supported=["read"],
|
||||
resource_name="Root Resource",
|
||||
)
|
||||
|
||||
app = Starlette(routes=protected_resource_routes)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def root_resource_client(root_resource_app: Starlette):
|
||||
"""Fixture to create an HTTP client for the root resource app."""
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=root_resource_app), base_url="https://mcptest.com"
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncClient):
|
||||
"""Test metadata endpoint for root-level resource."""
|
||||
|
||||
# For root resource, metadata should be at standard location
|
||||
response = await root_resource_client.get("/.well-known/oauth-protected-resource")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == snapshot(
|
||||
{
|
||||
"resource": "https://example.com/",
|
||||
"authorization_servers": ["https://auth.example.com/"],
|
||||
"scopes_supported": ["read"],
|
||||
"resource_name": "Root Resource",
|
||||
"bearer_methods_supported": ["header"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Tests for URL construction utility function
|
||||
|
||||
|
||||
def test_metadata_url_construction_url_without_path():
|
||||
"""Test URL construction for resource without path component."""
|
||||
resource_url = AnyHttpUrl("https://example.com")
|
||||
result = build_resource_metadata_url(resource_url)
|
||||
assert str(result) == "https://example.com/.well-known/oauth-protected-resource"
|
||||
|
||||
|
||||
def test_metadata_url_construction_url_with_path_component():
|
||||
"""Test URL construction for resource with path component."""
|
||||
resource_url = AnyHttpUrl("https://example.com/mcp")
|
||||
result = build_resource_metadata_url(resource_url)
|
||||
assert str(result) == "https://example.com/.well-known/oauth-protected-resource/mcp"
|
||||
|
||||
|
||||
def test_metadata_url_construction_url_with_trailing_slash_only():
|
||||
"""Test URL construction for resource with trailing slash only."""
|
||||
resource_url = AnyHttpUrl("https://example.com/")
|
||||
result = build_resource_metadata_url(resource_url)
|
||||
# Trailing slash should be treated as empty path
|
||||
assert str(result) == "https://example.com/.well-known/oauth-protected-resource"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resource_url,expected_url",
|
||||
[
|
||||
("https://example.com", "https://example.com/.well-known/oauth-protected-resource"),
|
||||
("https://example.com/", "https://example.com/.well-known/oauth-protected-resource"),
|
||||
("https://example.com/mcp", "https://example.com/.well-known/oauth-protected-resource/mcp"),
|
||||
("http://localhost:8001/mcp", "http://localhost:8001/.well-known/oauth-protected-resource/mcp"),
|
||||
],
|
||||
)
|
||||
def test_metadata_url_construction_various_resource_configurations(resource_url: str, expected_url: str):
|
||||
"""Test URL construction with various resource configurations."""
|
||||
result = build_resource_metadata_url(AnyHttpUrl(resource_url))
|
||||
assert str(result) == expected_url
|
||||
|
||||
|
||||
# Tests for consistency between URL generation and route registration
|
||||
|
||||
|
||||
def test_route_consistency_route_path_matches_metadata_url():
|
||||
"""Test that route path matches the generated metadata URL."""
|
||||
resource_url = AnyHttpUrl("https://example.com/mcp")
|
||||
|
||||
# Generate metadata URL
|
||||
metadata_url = build_resource_metadata_url(resource_url)
|
||||
|
||||
# Create routes
|
||||
routes = create_protected_resource_routes(
|
||||
resource_url=resource_url,
|
||||
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
||||
)
|
||||
|
||||
# Extract path from metadata URL
|
||||
metadata_path = urlparse(str(metadata_url)).path
|
||||
|
||||
# Verify consistency
|
||||
assert len(routes) == 1
|
||||
assert routes[0].path == metadata_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resource_url,expected_path",
|
||||
[
|
||||
("https://example.com", "/.well-known/oauth-protected-resource"),
|
||||
("https://example.com/", "/.well-known/oauth-protected-resource"),
|
||||
("https://example.com/mcp", "/.well-known/oauth-protected-resource/mcp"),
|
||||
],
|
||||
)
|
||||
def test_route_consistency_consistent_paths_for_various_resources(resource_url: str, expected_path: str):
|
||||
"""Test that URL generation and route creation are consistent."""
|
||||
resource_url_obj = AnyHttpUrl(resource_url)
|
||||
|
||||
# Test URL generation
|
||||
metadata_url = build_resource_metadata_url(resource_url_obj)
|
||||
url_path = urlparse(str(metadata_url)).path
|
||||
|
||||
# Test route creation
|
||||
routes = create_protected_resource_routes(
|
||||
resource_url=resource_url_obj,
|
||||
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
||||
)
|
||||
route_path = routes[0].path
|
||||
|
||||
# Both should match expected path
|
||||
assert url_path == expected_path
|
||||
assert route_path == expected_path
|
||||
assert url_path == route_path
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests for mcp.server.auth.provider module."""
|
||||
|
||||
from mcp.server.auth.provider import AccessToken, construct_redirect_uri, principal_components
|
||||
|
||||
|
||||
def test_construct_redirect_uri_no_existing_params():
|
||||
"""Test construct_redirect_uri with no existing query parameters."""
|
||||
base_uri = "http://localhost:8000/callback"
|
||||
result = construct_redirect_uri(base_uri, code="auth_code", state="test_state")
|
||||
|
||||
assert "http://localhost:8000/callback?code=auth_code&state=test_state" == result
|
||||
|
||||
|
||||
def test_construct_redirect_uri_with_existing_params():
|
||||
"""Test construct_redirect_uri with existing query parameters (regression test for #1279)."""
|
||||
base_uri = "http://localhost:8000/callback?session_id=1234"
|
||||
result = construct_redirect_uri(base_uri, code="auth_code", state="test_state")
|
||||
|
||||
# Should preserve existing params and add new ones
|
||||
assert "session_id=1234" in result
|
||||
assert "code=auth_code" in result
|
||||
assert "state=test_state" in result
|
||||
assert result.startswith("http://localhost:8000/callback?")
|
||||
|
||||
|
||||
def test_construct_redirect_uri_multiple_existing_params():
|
||||
"""Test construct_redirect_uri with multiple existing query parameters."""
|
||||
base_uri = "http://localhost:8000/callback?session_id=1234&user=test"
|
||||
result = construct_redirect_uri(base_uri, code="auth_code")
|
||||
|
||||
assert "session_id=1234" in result
|
||||
assert "user=test" in result
|
||||
assert "code=auth_code" in result
|
||||
|
||||
|
||||
def test_construct_redirect_uri_with_none_values():
|
||||
"""Test construct_redirect_uri filters out None values."""
|
||||
base_uri = "http://localhost:8000/callback"
|
||||
result = construct_redirect_uri(base_uri, code="auth_code", state=None)
|
||||
|
||||
assert result == "http://localhost:8000/callback?code=auth_code"
|
||||
assert "state" not in result
|
||||
|
||||
|
||||
def test_construct_redirect_uri_empty_params():
|
||||
"""Test construct_redirect_uri with no additional parameters."""
|
||||
base_uri = "http://localhost:8000/callback?existing=param"
|
||||
result = construct_redirect_uri(base_uri)
|
||||
|
||||
assert result == "http://localhost:8000/callback?existing=param"
|
||||
|
||||
|
||||
def test_construct_redirect_uri_duplicate_param_names():
|
||||
"""Test construct_redirect_uri when adding param that already exists."""
|
||||
base_uri = "http://localhost:8000/callback?code=existing"
|
||||
result = construct_redirect_uri(base_uri, code="new_code")
|
||||
|
||||
# Should contain both values (this is expected behavior of parse_qs/urlencode)
|
||||
assert "code=existing" in result
|
||||
assert "code=new_code" in result
|
||||
|
||||
|
||||
def test_construct_redirect_uri_multivalued_existing_params():
|
||||
"""Test construct_redirect_uri with existing multi-valued parameters."""
|
||||
base_uri = "http://localhost:8000/callback?scope=read&scope=write"
|
||||
result = construct_redirect_uri(base_uri, code="auth_code")
|
||||
|
||||
assert "scope=read" in result
|
||||
assert "scope=write" in result
|
||||
assert "code=auth_code" in result
|
||||
|
||||
|
||||
def test_construct_redirect_uri_encoded_values():
|
||||
"""Test construct_redirect_uri handles URL encoding properly."""
|
||||
base_uri = "http://localhost:8000/callback"
|
||||
result = construct_redirect_uri(base_uri, state="test state with spaces")
|
||||
|
||||
# urlencode uses + for spaces by default
|
||||
assert "state=test+state+with+spaces" in result
|
||||
|
||||
|
||||
def test_principal_components_composes_client_issuer_subject():
|
||||
"""The triple identifying a token's principal, degrading per missing component."""
|
||||
bare = AccessToken(token="t", client_id="client-1", scopes=[])
|
||||
assert principal_components(bare) == ("client-1", None, None)
|
||||
|
||||
full = AccessToken(
|
||||
token="t", client_id="client-1", scopes=[], subject="alice", claims={"iss": "https://as.example"}
|
||||
)
|
||||
assert principal_components(full) == ("client-1", "https://as.example", "alice")
|
||||
@@ -0,0 +1,72 @@
|
||||
import pytest
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from mcp.server.auth.routes import build_metadata, validate_issuer_url
|
||||
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
|
||||
|
||||
|
||||
def test_validate_issuer_url_https_allowed():
|
||||
validate_issuer_url(AnyHttpUrl("https://example.com/path"))
|
||||
|
||||
|
||||
def test_validate_issuer_url_http_localhost_allowed():
|
||||
validate_issuer_url(AnyHttpUrl("http://localhost:8080/path"))
|
||||
|
||||
|
||||
def test_validate_issuer_url_http_127_0_0_1_allowed():
|
||||
validate_issuer_url(AnyHttpUrl("http://127.0.0.1:8080/path"))
|
||||
|
||||
|
||||
def test_validate_issuer_url_http_ipv6_loopback_allowed():
|
||||
validate_issuer_url(AnyHttpUrl("http://[::1]:8080/path"))
|
||||
|
||||
|
||||
def test_validate_issuer_url_http_non_loopback_rejected():
|
||||
with pytest.raises(ValueError, match="Issuer URL must be HTTPS"):
|
||||
validate_issuer_url(AnyHttpUrl("http://evil.com/path"))
|
||||
|
||||
|
||||
def test_validate_issuer_url_http_127_prefix_domain_rejected():
|
||||
"""A domain like 127.0.0.1.evil.com is not loopback."""
|
||||
with pytest.raises(ValueError, match="Issuer URL must be HTTPS"):
|
||||
validate_issuer_url(AnyHttpUrl("http://127.0.0.1.evil.com/path"))
|
||||
|
||||
|
||||
def test_validate_issuer_url_http_127_prefix_subdomain_rejected():
|
||||
"""A domain like 127.0.0.1something.example.com is not loopback."""
|
||||
with pytest.raises(ValueError, match="Issuer URL must be HTTPS"):
|
||||
validate_issuer_url(AnyHttpUrl("http://127.0.0.1something.example.com/path"))
|
||||
|
||||
|
||||
def test_validate_issuer_url_fragment_rejected():
|
||||
with pytest.raises(ValueError, match="fragment"):
|
||||
validate_issuer_url(AnyHttpUrl("https://example.com/path#frag"))
|
||||
|
||||
|
||||
def test_validate_issuer_url_query_rejected():
|
||||
with pytest.raises(ValueError, match="query"):
|
||||
validate_issuer_url(AnyHttpUrl("https://example.com/path?q=1"))
|
||||
|
||||
|
||||
def test_auth_settings_preserves_path_less_issuer():
|
||||
"""A path-less issuer passed as a string keeps its canonical form (no trailing slash)."""
|
||||
settings = AuthSettings(
|
||||
issuer_url="https://as.example.com", # type: ignore[arg-type]
|
||||
resource_server_url="https://rs.example.com", # type: ignore[arg-type]
|
||||
)
|
||||
assert str(settings.issuer_url) == "https://as.example.com"
|
||||
assert str(settings.resource_server_url) == "https://rs.example.com"
|
||||
|
||||
|
||||
def test_build_metadata_serves_issuer_without_trailing_slash():
|
||||
"""The served issuer matches the configured one exactly (RFC 8414/9207 string comparison)."""
|
||||
settings = AuthSettings(
|
||||
issuer_url="https://as.example.com", # type: ignore[arg-type]
|
||||
resource_server_url="https://rs.example.com", # type: ignore[arg-type]
|
||||
)
|
||||
metadata = build_metadata(settings.issuer_url, None, ClientRegistrationOptions(), RevocationOptions())
|
||||
|
||||
served = metadata.model_dump(mode="json", exclude_none=True)
|
||||
assert served["issuer"] == "https://as.example.com"
|
||||
assert served["authorization_endpoint"] == "https://as.example.com/authorize"
|
||||
assert served["token_endpoint"] == "https://as.example.com/token"
|
||||
Reference in New Issue
Block a user