Skip to content

Commit 1dda3cd

Browse files
committed
feat(examples): client_credentials in simple-auth, multiprotocol discovery variants
- simple-auth: exchange_client_credentials, demo client_credentials client - simple-auth-multiprotocol: add prm_only, path_only, root_only, oauth_fallback server variants and CLI entry points for discovery E2E testing
1 parent 53b11f9 commit 1dda3cd

22 files changed

Lines changed: 1854 additions & 1 deletion

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""MCP Resource Server (multiprotocol, OAuth-fallback discovery variant)."""
2+
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Entry point for multi-protocol MCP Resource Server (OAuth-fallback discovery)."""
2+
3+
import sys
4+
5+
from mcp_simple_auth_multiprotocol_oauth_fallback.server import main
6+
7+
sys.exit(main()) # type: ignore[call-arg]
8+
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Multi-protocol auth adapter for OAuth-fallback discovery variant."""
2+
3+
import logging
4+
import time
5+
from typing import Any, cast
6+
7+
from starlette.authentication import AuthCredentials, AuthenticationBackend
8+
from starlette.requests import HTTPConnection, Request
9+
10+
from mcp.server.auth.dpop import DPoPProofVerifier, InMemoryJTIReplayStore
11+
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
12+
from mcp.server.auth.provider import AccessToken
13+
from mcp.server.auth.verifiers import (
14+
APIKeyVerifier,
15+
CredentialVerifier,
16+
MultiProtocolAuthBackend,
17+
OAuthTokenVerifier,
18+
)
19+
20+
logger = logging.getLogger(__name__)
21+
22+
23+
class MutualTLSVerifier:
24+
"""Placeholder verifier for Mutual TLS."""
25+
26+
async def verify(
27+
self,
28+
request: Any,
29+
dpop_verifier: Any = None,
30+
) -> AccessToken | None:
31+
return None
32+
33+
34+
def build_multiprotocol_backend(
35+
oauth_token_verifier: Any,
36+
api_key_valid_keys: set[str],
37+
api_key_scopes: list[str] | None = None,
38+
dpop_enabled: bool = False,
39+
) -> tuple[MultiProtocolAuthBackend, DPoPProofVerifier | None]:
40+
"""Build MultiProtocolAuthBackend with OAuth, API Key, and mTLS (placeholder) verifiers."""
41+
oauth_verifier = OAuthTokenVerifier(oauth_token_verifier)
42+
api_key_verifier = APIKeyVerifier(
43+
valid_keys=api_key_valid_keys,
44+
scopes=api_key_scopes or [],
45+
)
46+
mtls_verifier: CredentialVerifier = MutualTLSVerifier()
47+
backend = MultiProtocolAuthBackend(
48+
verifiers=[oauth_verifier, api_key_verifier, mtls_verifier]
49+
)
50+
51+
dpop_verifier: DPoPProofVerifier | None = None
52+
if dpop_enabled:
53+
dpop_verifier = DPoPProofVerifier(jti_store=InMemoryJTIReplayStore())
54+
55+
return backend, dpop_verifier
56+
57+
58+
class MultiProtocolAuthBackendAdapter(AuthenticationBackend):
59+
"""Starlette AuthenticationBackend that wraps MultiProtocolAuthBackend."""
60+
61+
def __init__(
62+
self,
63+
backend: MultiProtocolAuthBackend,
64+
dpop_verifier: DPoPProofVerifier | None = None,
65+
) -> None:
66+
self._backend = backend
67+
self._dpop_verifier = dpop_verifier
68+
69+
async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, AuthenticatedUser] | None:
70+
request = cast(Request, conn)
71+
72+
dpop_header = request.headers.get("dpop")
73+
if self._dpop_verifier is not None:
74+
if dpop_header:
75+
logger.info("DPoP proof present, verification enabled")
76+
else:
77+
logger.debug("DPoP verification enabled but no DPoP header in request")
78+
elif dpop_header:
79+
logger.debug("DPoP header present but verification not enabled (ignoring)")
80+
81+
result = await self._backend.verify(request, dpop_verifier=self._dpop_verifier)
82+
83+
if result is None:
84+
if dpop_header and self._dpop_verifier is not None:
85+
logger.warning("Authentication failed (DPoP proof may be invalid)")
86+
else:
87+
logger.debug("Authentication failed (no valid credentials)")
88+
return None
89+
90+
if result.expires_at is not None and result.expires_at < int(time.time()):
91+
logger.warning("Token expired for client_id=%s", result.client_id)
92+
return None
93+
94+
if dpop_header and self._dpop_verifier is not None:
95+
logger.info("Authentication successful with DPoP (client_id=%s)", result.client_id)
96+
else:
97+
logger.info("Authentication successful (client_id=%s)", result.client_id)
98+
99+
return (
100+
AuthCredentials(result.scopes or []),
101+
AuthenticatedUser(result),
102+
)
103+
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
"""
2+
MCP Resource Server with multi-protocol auth (OAuth-fallback discovery variant).
3+
4+
This variant:
5+
- PRM does NOT include mcp_auth_protocols (only authorization_servers)
6+
- Does NOT expose any unified discovery endpoints
7+
- Forces clients to use OAuth fallback from PRM.authorization_servers
8+
"""
9+
10+
import contextlib
11+
import datetime
12+
import logging
13+
from typing import Any, Literal
14+
15+
import click
16+
import uvicorn
17+
from pydantic import AnyHttpUrl
18+
from pydantic_settings import BaseSettings, SettingsConfigDict
19+
from starlette.applications import Starlette
20+
from starlette.middleware import Middleware
21+
from starlette.middleware.authentication import AuthenticationMiddleware
22+
from starlette.routing import Route
23+
from starlette.types import ASGIApp
24+
25+
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
26+
from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware
27+
from mcp.server.auth.routes import (
28+
build_resource_metadata_url,
29+
create_protected_resource_routes,
30+
)
31+
from mcp.server.auth.settings import AuthSettings
32+
from mcp.server.fastmcp.server import FastMCP, StreamableHTTPASGIApp
33+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
34+
from mcp.shared.auth import AuthProtocolMetadata
35+
36+
from .multiprotocol import MultiProtocolAuthBackendAdapter, build_multiprotocol_backend
37+
from .token_verifier import IntrospectionTokenVerifier
38+
39+
logger = logging.getLogger(__name__)
40+
41+
42+
class ResourceServerSettings(BaseSettings):
43+
"""Settings for the multi-protocol MCP Resource Server (OAuth-fallback discovery)."""
44+
45+
model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_")
46+
47+
host: str = "localhost"
48+
port: int = 8002
49+
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8002/mcp")
50+
auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
51+
auth_server_introspection_endpoint: str = "http://localhost:9000/introspect"
52+
mcp_scope: str = "user"
53+
oauth_strict: bool = False
54+
api_key_valid_keys: str = "demo-api-key-12345"
55+
default_protocol: str = "oauth2"
56+
protocol_preferences: str = "oauth2:1,api_key:2,mutual_tls:3"
57+
dpop_enabled: bool = False
58+
59+
60+
def _protocol_metadata_list(settings: ResourceServerSettings) -> list[AuthProtocolMetadata]:
61+
"""Build AuthProtocolMetadata for oauth2, api_key, mutual_tls."""
62+
auth_base = str(settings.auth_server_url).rstrip("/")
63+
oauth_metadata_url = AnyHttpUrl(f"{auth_base}/.well-known/oauth-authorization-server")
64+
return [
65+
AuthProtocolMetadata(
66+
protocol_id="oauth2",
67+
protocol_version="2.0",
68+
metadata_url=oauth_metadata_url,
69+
scopes_supported=[settings.mcp_scope],
70+
),
71+
AuthProtocolMetadata(protocol_id="api_key", protocol_version="1.0"),
72+
AuthProtocolMetadata(protocol_id="mutual_tls", protocol_version="1.0"),
73+
]
74+
75+
76+
def _protocol_preferences_dict(prefs_str: str) -> dict[str, int]:
77+
"""Parse protocol_preferences string like 'oauth2:1,api_key:2,mutual_tls:3'."""
78+
out: dict[str, int] = {}
79+
for part in prefs_str.split(","):
80+
s = part.strip()
81+
if ":" in s:
82+
proto, prio = s.split(":", 1)
83+
try:
84+
out[proto.strip()] = int(prio.strip())
85+
except ValueError:
86+
pass
87+
return out
88+
89+
90+
def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> Starlette:
91+
"""Create Starlette app with MultiProtocolAuthBackend and PRM-only (no mcp_auth_protocols, no unified discovery)."""
92+
oauth_verifier = IntrospectionTokenVerifier(
93+
introspection_endpoint=settings.auth_server_introspection_endpoint,
94+
server_url=str(settings.server_url),
95+
validate_resource=settings.oauth_strict,
96+
)
97+
api_key_keys = {k.strip() for k in settings.api_key_valid_keys.split(",") if k.strip()}
98+
backend, dpop_verifier = build_multiprotocol_backend(
99+
oauth_verifier,
100+
api_key_keys,
101+
api_key_scopes=[settings.mcp_scope],
102+
dpop_enabled=settings.dpop_enabled,
103+
)
104+
adapter = MultiProtocolAuthBackendAdapter(backend, dpop_verifier=dpop_verifier)
105+
106+
fastmcp = FastMCP(
107+
name="MCP Resource Server (multiprotocol, OAuth-fallback discovery)",
108+
instructions="Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth (OAuth-fallback discovery)",
109+
host=settings.host,
110+
port=settings.port,
111+
auth=None,
112+
)
113+
114+
@fastmcp.tool()
115+
async def get_time() -> dict[str, Any]:
116+
"""Return current server time (requires auth)."""
117+
now = datetime.datetime.now()
118+
return {
119+
"current_time": now.isoformat(),
120+
"timezone": "UTC",
121+
"timestamp": now.timestamp(),
122+
"formatted": now.strftime("%Y-%m-%d %H:%M:%S"),
123+
}
124+
125+
mcp_server = getattr(fastmcp, "_mcp_server")
126+
session_manager = StreamableHTTPSessionManager(
127+
app=mcp_server,
128+
event_store=None,
129+
retry_interval=None,
130+
json_response=False,
131+
stateless=False,
132+
security_settings=None,
133+
)
134+
streamable_app: ASGIApp = StreamableHTTPASGIApp(session_manager)
135+
136+
auth_settings = AuthSettings(
137+
issuer_url=settings.auth_server_url,
138+
required_scopes=[settings.mcp_scope],
139+
resource_server_url=settings.server_url,
140+
)
141+
resource_url = auth_settings.resource_server_url
142+
assert resource_url is not None
143+
resource_metadata_url = build_resource_metadata_url(resource_url)
144+
# We still define full protocol metadata for logging/reference, but PRM will not include mcp_auth_protocols
145+
protocols_metadata = _protocol_metadata_list(settings)
146+
auth_protocol_ids = [p.protocol_id for p in protocols_metadata]
147+
protocol_prefs = _protocol_preferences_dict(settings.protocol_preferences)
148+
149+
require_auth = RequireAuthMiddleware(
150+
streamable_app,
151+
required_scopes=[settings.mcp_scope],
152+
resource_metadata_url=resource_metadata_url,
153+
auth_protocols=auth_protocol_ids,
154+
default_protocol=settings.default_protocol,
155+
protocol_preferences=protocol_prefs if protocol_prefs else None,
156+
)
157+
158+
routes: list[Route] = [
159+
Route(
160+
"/mcp",
161+
endpoint=require_auth,
162+
),
163+
]
164+
# PRM without mcp_auth_protocols: only authorization_servers/scopes
165+
routes.extend(
166+
create_protected_resource_routes(
167+
resource_url=resource_url,
168+
authorization_servers=[auth_settings.issuer_url],
169+
scopes_supported=auth_settings.required_scopes,
170+
# IMPORTANT: pass an explicit empty list to avoid ProtectedResourceMetadata backward-compat
171+
# validator auto-filling mcp_auth_protocols from authorization_servers.
172+
auth_protocols=[],
173+
default_protocol=None,
174+
protocol_preferences=None,
175+
)
176+
)
177+
178+
# NOTE: OAuth-fallback variant intentionally does NOT add any unified discovery routes:
179+
# - No /.well-known/authorization_servers
180+
# - No /.well-known/authorization_servers/mcp
181+
182+
middleware = [
183+
Middleware(AuthenticationMiddleware, backend=adapter),
184+
Middleware(AuthContextMiddleware),
185+
]
186+
187+
@contextlib.asynccontextmanager
188+
async def lifespan(app: Starlette):
189+
async with session_manager.run():
190+
yield
191+
192+
return Starlette(
193+
debug=True,
194+
routes=routes,
195+
middleware=middleware,
196+
lifespan=lifespan,
197+
)
198+
199+
200+
@click.command()
201+
@click.option("--port", default=8002, help="Port to listen on")
202+
@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL")
203+
@click.option(
204+
"--transport",
205+
default="streamable-http",
206+
type=click.Choice(["sse", "streamable-http"]),
207+
help="Transport protocol",
208+
)
209+
@click.option("--oauth-strict", is_flag=True, help="Enable RFC 8707 resource validation")
210+
@click.option("--api-keys", default="demo-api-key-12345", help="Comma-separated valid API keys")
211+
@click.option("--dpop-enabled", is_flag=True, help="Enable DPoP proof verification (RFC 9449)")
212+
def main(
213+
port: int,
214+
auth_server: str,
215+
transport: Literal["sse", "streamable-http"],
216+
oauth_strict: bool,
217+
api_keys: str,
218+
dpop_enabled: bool,
219+
) -> int:
220+
"""Run the multi-protocol MCP Resource Server (OAuth-fallback discovery)."""
221+
logging.basicConfig(level=logging.INFO)
222+
try:
223+
host = "localhost"
224+
server_url = f"http://{host}:{port}/mcp"
225+
settings = ResourceServerSettings(
226+
host=host,
227+
port=port,
228+
server_url=AnyHttpUrl(server_url),
229+
auth_server_url=AnyHttpUrl(auth_server),
230+
auth_server_introspection_endpoint=f"{auth_server}/introspect",
231+
oauth_strict=oauth_strict,
232+
api_key_valid_keys=api_keys,
233+
dpop_enabled=dpop_enabled,
234+
)
235+
except ValueError as e:
236+
logger.error("Configuration error: %s", e)
237+
return 1
238+
239+
app = create_multiprotocol_resource_server(settings)
240+
logger.info("Multi-protocol RS (OAuth-fallback discovery) running on %s", settings.server_url)
241+
logger.info("Auth: OAuth (introspection), API Key (X-API-Key or Bearer <key>), mTLS (placeholder)")
242+
if dpop_enabled:
243+
logger.info("DPoP: enabled (RFC 9449)")
244+
uvicorn.run(app, host=settings.host, port=settings.port)
245+
return 0
246+
247+
248+
if __name__ == "__main__":
249+
main() # type: ignore[call-arg]
250+

0 commit comments

Comments
 (0)