Remove github from auth examples (#1011)
This commit is contained in:
parent
ecc916516d
commit
ce007de66d
6 changed files with 376 additions and 502 deletions
|
|
@ -4,20 +4,6 @@ This example demonstrates OAuth 2.0 authentication with the Model Context Protoc
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setup Requirements
|
|
||||||
|
|
||||||
**Create a GitHub OAuth App:**
|
|
||||||
- Go to GitHub Settings > Developer settings > OAuth Apps > New OAuth App
|
|
||||||
- **Authorization callback URL:** `http://localhost:9000/github/callback`
|
|
||||||
- Note down your **Client ID** and **Client Secret**
|
|
||||||
|
|
||||||
**Set environment variables:**
|
|
||||||
```bash
|
|
||||||
export MCP_GITHUB_CLIENT_ID="your_client_id_here"
|
|
||||||
export MCP_GITHUB_CLIENT_SECRET="your_client_secret_here"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Running the Servers
|
## Running the Servers
|
||||||
|
|
||||||
|
|
@ -33,9 +19,8 @@ uv run mcp-simple-auth-as --port=9000
|
||||||
|
|
||||||
**What it provides:**
|
**What it provides:**
|
||||||
- OAuth 2.0 flows (registration, authorization, token exchange)
|
- OAuth 2.0 flows (registration, authorization, token exchange)
|
||||||
- GitHub OAuth integration for user authentication
|
- Simple credential-based authentication (no external provider needed)
|
||||||
- Token introspection endpoint for Resource Servers (`/introspect`)
|
- Token introspection endpoint for Resource Servers (`/introspect`)
|
||||||
- User data proxy endpoint (`/github/user`)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -90,6 +75,7 @@ curl http://localhost:9000/.well-known/oauth-authorization-server
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Legacy MCP Server as Authorization Server (Backwards Compatibility)
|
## Legacy MCP Server as Authorization Server (Backwards Compatibility)
|
||||||
|
|
||||||
For backwards compatibility with older MCP implementations, a legacy server is provided that acts as an Authorization Server (following the old spec where MCP servers could optionally provide OAuth):
|
For backwards compatibility with older MCP implementations, a legacy server is provided that acts as an Authorization Server (following the old spec where MCP servers could optionally provide OAuth):
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ Can be replaced with enterprise authorization servers like Auth0, Entra ID, etc.
|
||||||
NOTE: this is a simplified example for demonstration purposes.
|
NOTE: this is a simplified example for demonstration purposes.
|
||||||
This is not a production-ready implementation.
|
This is not a production-ready implementation.
|
||||||
|
|
||||||
Usage:
|
|
||||||
python -m mcp_simple_auth.auth_server --port=9000
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
@ -20,14 +18,14 @@ from pydantic import AnyHttpUrl, BaseModel
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse, RedirectResponse, Response
|
from starlette.responses import JSONResponse, Response
|
||||||
from starlette.routing import Route
|
from starlette.routing import Route
|
||||||
from uvicorn import Config, Server
|
from uvicorn import Config, Server
|
||||||
|
|
||||||
from mcp.server.auth.routes import cors_middleware, create_auth_routes
|
from mcp.server.auth.routes import cors_middleware, create_auth_routes
|
||||||
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
|
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
|
||||||
|
|
||||||
from .github_oauth_provider import GitHubOAuthProvider, GitHubOAuthSettings
|
from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -39,60 +37,64 @@ class AuthServerSettings(BaseModel):
|
||||||
host: str = "localhost"
|
host: str = "localhost"
|
||||||
port: int = 9000
|
port: int = 9000
|
||||||
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
|
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
|
||||||
github_callback_path: str = "http://localhost:9000/github/callback"
|
auth_callback_path: str = "http://localhost:9000/login/callback"
|
||||||
|
|
||||||
|
|
||||||
class GitHubProxyAuthProvider(GitHubOAuthProvider):
|
class SimpleAuthProvider(SimpleOAuthProvider):
|
||||||
"""
|
"""
|
||||||
Authorization Server provider that proxies GitHub OAuth.
|
Authorization Server provider with simple demo authentication.
|
||||||
|
|
||||||
This provider:
|
This provider:
|
||||||
1. Issues MCP tokens after GitHub authentication
|
1. Issues MCP tokens after simple credential authentication
|
||||||
2. Stores token state for introspection by Resource Servers
|
2. Stores token state for introspection by Resource Servers
|
||||||
3. Maps MCP tokens to GitHub tokens for API access
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, github_settings: GitHubOAuthSettings, github_callback_path: str):
|
def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str):
|
||||||
super().__init__(github_settings, github_callback_path)
|
super().__init__(auth_settings, auth_callback_path, server_url)
|
||||||
|
|
||||||
|
|
||||||
def create_authorization_server(server_settings: AuthServerSettings, github_settings: GitHubOAuthSettings) -> Starlette:
|
def create_authorization_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings) -> Starlette:
|
||||||
"""Create the Authorization Server application."""
|
"""Create the Authorization Server application."""
|
||||||
oauth_provider = GitHubProxyAuthProvider(github_settings, server_settings.github_callback_path)
|
oauth_provider = SimpleAuthProvider(
|
||||||
|
auth_settings, server_settings.auth_callback_path, str(server_settings.server_url)
|
||||||
|
)
|
||||||
|
|
||||||
auth_settings = AuthSettings(
|
mcp_auth_settings = AuthSettings(
|
||||||
issuer_url=server_settings.server_url,
|
issuer_url=server_settings.server_url,
|
||||||
client_registration_options=ClientRegistrationOptions(
|
client_registration_options=ClientRegistrationOptions(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
valid_scopes=[github_settings.mcp_scope],
|
valid_scopes=[auth_settings.mcp_scope],
|
||||||
default_scopes=[github_settings.mcp_scope],
|
default_scopes=[auth_settings.mcp_scope],
|
||||||
),
|
),
|
||||||
required_scopes=[github_settings.mcp_scope],
|
required_scopes=[auth_settings.mcp_scope],
|
||||||
resource_server_url=None,
|
resource_server_url=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create OAuth routes
|
# Create OAuth routes
|
||||||
routes = create_auth_routes(
|
routes = create_auth_routes(
|
||||||
provider=oauth_provider,
|
provider=oauth_provider,
|
||||||
issuer_url=auth_settings.issuer_url,
|
issuer_url=mcp_auth_settings.issuer_url,
|
||||||
service_documentation_url=auth_settings.service_documentation_url,
|
service_documentation_url=mcp_auth_settings.service_documentation_url,
|
||||||
client_registration_options=auth_settings.client_registration_options,
|
client_registration_options=mcp_auth_settings.client_registration_options,
|
||||||
revocation_options=auth_settings.revocation_options,
|
revocation_options=mcp_auth_settings.revocation_options,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add GitHub callback route
|
# Add login page route (GET)
|
||||||
async def github_callback_handler(request: Request) -> Response:
|
async def login_page_handler(request: Request) -> Response:
|
||||||
"""Handle GitHub OAuth callback."""
|
"""Show login form."""
|
||||||
code = request.query_params.get("code")
|
|
||||||
state = request.query_params.get("state")
|
state = request.query_params.get("state")
|
||||||
|
if not state:
|
||||||
|
raise HTTPException(400, "Missing state parameter")
|
||||||
|
return await oauth_provider.get_login_page(state)
|
||||||
|
|
||||||
if not code or not state:
|
routes.append(Route("/login", endpoint=login_page_handler, methods=["GET"]))
|
||||||
raise HTTPException(400, "Missing code or state parameter")
|
|
||||||
|
|
||||||
redirect_uri = await oauth_provider.handle_github_callback(code, state)
|
# Add login callback route (POST)
|
||||||
return RedirectResponse(url=redirect_uri, status_code=302)
|
async def login_callback_handler(request: Request) -> Response:
|
||||||
|
"""Handle simple authentication callback."""
|
||||||
|
return await oauth_provider.handle_login_callback(request)
|
||||||
|
|
||||||
routes.append(Route("/github/callback", endpoint=github_callback_handler, methods=["GET"]))
|
routes.append(Route("/login/callback", endpoint=login_callback_handler, methods=["POST"]))
|
||||||
|
|
||||||
# Add token introspection endpoint (RFC 7662) for Resource Servers
|
# Add token introspection endpoint (RFC 7662) for Resource Servers
|
||||||
async def introspect_handler(request: Request) -> Response:
|
async def introspect_handler(request: Request) -> Response:
|
||||||
|
|
@ -112,7 +114,6 @@ def create_authorization_server(server_settings: AuthServerSettings, github_sett
|
||||||
if not access_token:
|
if not access_token:
|
||||||
return JSONResponse({"active": False})
|
return JSONResponse({"active": False})
|
||||||
|
|
||||||
# Return token info for Resource Server
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{
|
{
|
||||||
"active": True,
|
"active": True,
|
||||||
|
|
@ -133,39 +134,12 @@ def create_authorization_server(server_settings: AuthServerSettings, github_sett
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add GitHub user info endpoint (for Resource Server to fetch user data)
|
|
||||||
async def github_user_handler(request: Request) -> Response:
|
|
||||||
"""
|
|
||||||
Proxy endpoint to get GitHub user info using stored GitHub tokens.
|
|
||||||
|
|
||||||
Resource Servers call this with MCP tokens to get GitHub user data
|
|
||||||
without exposing GitHub tokens to clients.
|
|
||||||
"""
|
|
||||||
# Extract Bearer token
|
|
||||||
auth_header = request.headers.get("authorization", "")
|
|
||||||
if not auth_header.startswith("Bearer "):
|
|
||||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
||||||
|
|
||||||
mcp_token = auth_header[7:]
|
|
||||||
|
|
||||||
# Get GitHub user info using the provider method
|
|
||||||
user_info = await oauth_provider.get_github_user_info(mcp_token)
|
|
||||||
return JSONResponse(user_info)
|
|
||||||
|
|
||||||
routes.append(
|
|
||||||
Route(
|
|
||||||
"/github/user",
|
|
||||||
endpoint=cors_middleware(github_user_handler, ["GET", "OPTIONS"]),
|
|
||||||
methods=["GET", "OPTIONS"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return Starlette(routes=routes)
|
return Starlette(routes=routes)
|
||||||
|
|
||||||
|
|
||||||
async def run_server(server_settings: AuthServerSettings, github_settings: GitHubOAuthSettings):
|
async def run_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings):
|
||||||
"""Run the Authorization Server."""
|
"""Run the Authorization Server."""
|
||||||
auth_server = create_authorization_server(server_settings, github_settings)
|
auth_server = create_authorization_server(server_settings, auth_settings)
|
||||||
|
|
||||||
config = Config(
|
config = Config(
|
||||||
auth_server,
|
auth_server,
|
||||||
|
|
@ -175,22 +149,7 @@ async def run_server(server_settings: AuthServerSettings, github_settings: GitHu
|
||||||
)
|
)
|
||||||
server = Server(config)
|
server = Server(config)
|
||||||
|
|
||||||
logger.info("=" * 80)
|
logger.info(f"🚀 MCP Authorization Server running on {server_settings.server_url}")
|
||||||
logger.info("MCP AUTHORIZATION SERVER")
|
|
||||||
logger.info("=" * 80)
|
|
||||||
logger.info(f"Server URL: {server_settings.server_url}")
|
|
||||||
logger.info("Endpoints:")
|
|
||||||
logger.info(f" - OAuth Metadata: {server_settings.server_url}/.well-known/oauth-authorization-server")
|
|
||||||
logger.info(f" - Client Registration: {server_settings.server_url}/register")
|
|
||||||
logger.info(f" - Authorization: {server_settings.server_url}/authorize")
|
|
||||||
logger.info(f" - Token Exchange: {server_settings.server_url}/token")
|
|
||||||
logger.info(f" - Token Introspection: {server_settings.server_url}/introspect")
|
|
||||||
logger.info(f" - GitHub Callback: {server_settings.server_url}/github/callback")
|
|
||||||
logger.info(f" - GitHub User Proxy: {server_settings.server_url}/github/user")
|
|
||||||
logger.info("")
|
|
||||||
logger.info("Resource Servers should use /introspect to validate tokens")
|
|
||||||
logger.info("Configure GitHub App callback URL: " + server_settings.github_callback_path)
|
|
||||||
logger.info("=" * 80)
|
|
||||||
|
|
||||||
await server.serve()
|
await server.serve()
|
||||||
|
|
||||||
|
|
@ -203,18 +162,12 @@ def main(port: int) -> int:
|
||||||
|
|
||||||
This server handles OAuth flows and can be used by multiple Resource Servers.
|
This server handles OAuth flows and can be used by multiple Resource Servers.
|
||||||
|
|
||||||
Environment variables needed:
|
Uses simple hardcoded credentials for demo purposes.
|
||||||
- MCP_GITHUB_CLIENT_ID: GitHub OAuth Client ID
|
|
||||||
- MCP_GITHUB_CLIENT_SECRET: GitHub OAuth Client Secret
|
|
||||||
"""
|
"""
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
# Load GitHub settings from environment variables
|
# Load simple auth settings
|
||||||
github_settings = GitHubOAuthSettings()
|
auth_settings = SimpleAuthSettings()
|
||||||
|
|
||||||
# Validate required fields
|
|
||||||
if not github_settings.github_client_id or not github_settings.github_client_secret:
|
|
||||||
raise ValueError("GitHub credentials not provided")
|
|
||||||
|
|
||||||
# Create server settings
|
# Create server settings
|
||||||
host = "localhost"
|
host = "localhost"
|
||||||
|
|
@ -223,10 +176,10 @@ def main(port: int) -> int:
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
server_url=AnyHttpUrl(server_url),
|
server_url=AnyHttpUrl(server_url),
|
||||||
github_callback_path=f"{server_url}/github/callback",
|
auth_callback_path=f"{server_url}/login",
|
||||||
)
|
)
|
||||||
|
|
||||||
asyncio.run(run_server(server_settings, github_settings))
|
asyncio.run(run_server(server_settings, auth_settings))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,266 +0,0 @@
|
||||||
"""
|
|
||||||
Shared GitHub OAuth provider for MCP servers.
|
|
||||||
|
|
||||||
This module contains the common GitHub OAuth functionality used by both
|
|
||||||
the standalone authorization server and the legacy combined server.
|
|
||||||
|
|
||||||
NOTE: this is a simplified example for demonstration purposes.
|
|
||||||
This is not a production-ready implementation.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import AnyHttpUrl
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
from starlette.exceptions import HTTPException
|
|
||||||
|
|
||||||
from mcp.server.auth.provider import (
|
|
||||||
AccessToken,
|
|
||||||
AuthorizationCode,
|
|
||||||
AuthorizationParams,
|
|
||||||
OAuthAuthorizationServerProvider,
|
|
||||||
RefreshToken,
|
|
||||||
construct_redirect_uri,
|
|
||||||
)
|
|
||||||
from mcp.shared._httpx_utils import create_mcp_http_client
|
|
||||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class GitHubOAuthSettings(BaseSettings):
|
|
||||||
"""Common GitHub OAuth settings."""
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_prefix="MCP_")
|
|
||||||
|
|
||||||
# GitHub OAuth settings - MUST be provided via environment variables
|
|
||||||
github_client_id: str | None = None
|
|
||||||
github_client_secret: str | None = None
|
|
||||||
|
|
||||||
# GitHub OAuth URLs
|
|
||||||
github_auth_url: str = "https://github.com/login/oauth/authorize"
|
|
||||||
github_token_url: str = "https://github.com/login/oauth/access_token"
|
|
||||||
|
|
||||||
mcp_scope: str = "user"
|
|
||||||
github_scope: str = "read:user"
|
|
||||||
|
|
||||||
|
|
||||||
class GitHubOAuthProvider(OAuthAuthorizationServerProvider):
|
|
||||||
"""
|
|
||||||
OAuth provider that uses GitHub as the identity provider.
|
|
||||||
|
|
||||||
This provider handles the OAuth flow by:
|
|
||||||
1. Redirecting users to GitHub for authentication
|
|
||||||
2. Exchanging GitHub tokens for MCP tokens
|
|
||||||
3. Maintaining token mappings for API access
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, settings: GitHubOAuthSettings, github_callback_url: str):
|
|
||||||
self.settings = settings
|
|
||||||
self.github_callback_url = github_callback_url
|
|
||||||
self.clients: dict[str, OAuthClientInformationFull] = {}
|
|
||||||
self.auth_codes: dict[str, AuthorizationCode] = {}
|
|
||||||
self.tokens: dict[str, AccessToken] = {}
|
|
||||||
self.state_mapping: dict[str, dict[str, str | None]] = {}
|
|
||||||
# Maps MCP tokens to GitHub tokens
|
|
||||||
self.token_mapping: dict[str, str] = {}
|
|
||||||
|
|
||||||
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
|
||||||
"""Get OAuth client information."""
|
|
||||||
return self.clients.get(client_id)
|
|
||||||
|
|
||||||
async def register_client(self, client_info: OAuthClientInformationFull):
|
|
||||||
"""Register a new OAuth client."""
|
|
||||||
self.clients[client_info.client_id] = client_info
|
|
||||||
|
|
||||||
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
|
||||||
"""Generate an authorization URL for GitHub OAuth flow."""
|
|
||||||
state = params.state or secrets.token_hex(16)
|
|
||||||
|
|
||||||
# Store state mapping for callback
|
|
||||||
self.state_mapping[state] = {
|
|
||||||
"redirect_uri": str(params.redirect_uri),
|
|
||||||
"code_challenge": params.code_challenge,
|
|
||||||
"redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly),
|
|
||||||
"client_id": client.client_id,
|
|
||||||
"resource": params.resource, # RFC 8707
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build GitHub authorization URL
|
|
||||||
auth_url = (
|
|
||||||
f"{self.settings.github_auth_url}"
|
|
||||||
f"?client_id={self.settings.github_client_id}"
|
|
||||||
f"&redirect_uri={self.github_callback_url}"
|
|
||||||
f"&scope={self.settings.github_scope}"
|
|
||||||
f"&state={state}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return auth_url
|
|
||||||
|
|
||||||
async def handle_github_callback(self, code: str, state: str) -> str:
|
|
||||||
"""Handle GitHub OAuth callback and return redirect URI."""
|
|
||||||
state_data = self.state_mapping.get(state)
|
|
||||||
if not state_data:
|
|
||||||
raise HTTPException(400, "Invalid state parameter")
|
|
||||||
|
|
||||||
redirect_uri = state_data["redirect_uri"]
|
|
||||||
code_challenge = state_data["code_challenge"]
|
|
||||||
redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True"
|
|
||||||
client_id = state_data["client_id"]
|
|
||||||
resource = state_data.get("resource") # RFC 8707
|
|
||||||
|
|
||||||
# These are required values from our own state mapping
|
|
||||||
assert redirect_uri is not None
|
|
||||||
assert code_challenge is not None
|
|
||||||
assert client_id is not None
|
|
||||||
|
|
||||||
# Exchange code for token with GitHub
|
|
||||||
async with create_mcp_http_client() as client:
|
|
||||||
response = await client.post(
|
|
||||||
self.settings.github_token_url,
|
|
||||||
data={
|
|
||||||
"client_id": self.settings.github_client_id,
|
|
||||||
"client_secret": self.settings.github_client_secret,
|
|
||||||
"code": code,
|
|
||||||
"redirect_uri": self.github_callback_url,
|
|
||||||
},
|
|
||||||
headers={"Accept": "application/json"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise HTTPException(400, "Failed to exchange code for token")
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if "error" in data:
|
|
||||||
raise HTTPException(400, data.get("error_description", data["error"]))
|
|
||||||
|
|
||||||
github_token = data["access_token"]
|
|
||||||
|
|
||||||
# Create MCP authorization code
|
|
||||||
new_code = f"mcp_{secrets.token_hex(16)}"
|
|
||||||
auth_code = AuthorizationCode(
|
|
||||||
code=new_code,
|
|
||||||
client_id=client_id,
|
|
||||||
redirect_uri=AnyHttpUrl(redirect_uri),
|
|
||||||
redirect_uri_provided_explicitly=redirect_uri_provided_explicitly,
|
|
||||||
expires_at=time.time() + 300,
|
|
||||||
scopes=[self.settings.mcp_scope],
|
|
||||||
code_challenge=code_challenge,
|
|
||||||
resource=resource, # RFC 8707
|
|
||||||
)
|
|
||||||
self.auth_codes[new_code] = auth_code
|
|
||||||
|
|
||||||
# Store GitHub token with MCP client_id
|
|
||||||
self.tokens[github_token] = AccessToken(
|
|
||||||
token=github_token,
|
|
||||||
client_id=client_id,
|
|
||||||
scopes=[self.settings.github_scope],
|
|
||||||
expires_at=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
del self.state_mapping[state]
|
|
||||||
return construct_redirect_uri(redirect_uri, code=new_code, state=state)
|
|
||||||
|
|
||||||
async def load_authorization_code(
|
|
||||||
self, client: OAuthClientInformationFull, authorization_code: str
|
|
||||||
) -> AuthorizationCode | None:
|
|
||||||
"""Load an authorization code."""
|
|
||||||
return self.auth_codes.get(authorization_code)
|
|
||||||
|
|
||||||
async def exchange_authorization_code(
|
|
||||||
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
|
||||||
) -> OAuthToken:
|
|
||||||
"""Exchange authorization code for tokens."""
|
|
||||||
if authorization_code.code not in self.auth_codes:
|
|
||||||
raise ValueError("Invalid authorization code")
|
|
||||||
|
|
||||||
# Generate MCP access token
|
|
||||||
mcp_token = f"mcp_{secrets.token_hex(32)}"
|
|
||||||
|
|
||||||
# Store MCP token
|
|
||||||
self.tokens[mcp_token] = AccessToken(
|
|
||||||
token=mcp_token,
|
|
||||||
client_id=client.client_id,
|
|
||||||
scopes=authorization_code.scopes,
|
|
||||||
expires_at=int(time.time()) + 3600,
|
|
||||||
resource=authorization_code.resource, # RFC 8707
|
|
||||||
)
|
|
||||||
|
|
||||||
# Find GitHub token for this client
|
|
||||||
github_token = next(
|
|
||||||
(
|
|
||||||
token
|
|
||||||
for token, data in self.tokens.items()
|
|
||||||
if (token.startswith("ghu_") or token.startswith("gho_")) and data.client_id == client.client_id
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store mapping between MCP token and GitHub token
|
|
||||||
if github_token:
|
|
||||||
self.token_mapping[mcp_token] = github_token
|
|
||||||
|
|
||||||
del self.auth_codes[authorization_code.code]
|
|
||||||
|
|
||||||
return OAuthToken(
|
|
||||||
access_token=mcp_token,
|
|
||||||
token_type="Bearer",
|
|
||||||
expires_in=3600,
|
|
||||||
scope=" ".join(authorization_code.scopes),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def load_access_token(self, token: str) -> AccessToken | None:
|
|
||||||
"""Load and validate an access token."""
|
|
||||||
access_token = self.tokens.get(token)
|
|
||||||
if not access_token:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check if expired
|
|
||||||
if access_token.expires_at and access_token.expires_at < time.time():
|
|
||||||
del self.tokens[token]
|
|
||||||
return None
|
|
||||||
|
|
||||||
return access_token
|
|
||||||
|
|
||||||
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
|
|
||||||
"""Load a refresh token - not supported in this example."""
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def exchange_refresh_token(
|
|
||||||
self,
|
|
||||||
client: OAuthClientInformationFull,
|
|
||||||
refresh_token: RefreshToken,
|
|
||||||
scopes: list[str],
|
|
||||||
) -> OAuthToken:
|
|
||||||
"""Exchange refresh token - not supported in this example."""
|
|
||||||
raise NotImplementedError("Refresh tokens not supported")
|
|
||||||
|
|
||||||
async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None:
|
|
||||||
"""Revoke a token."""
|
|
||||||
if token in self.tokens:
|
|
||||||
del self.tokens[token]
|
|
||||||
|
|
||||||
async def get_github_user_info(self, mcp_token: str) -> dict[str, Any]:
|
|
||||||
"""Get GitHub user info using MCP token."""
|
|
||||||
github_token = self.token_mapping.get(mcp_token)
|
|
||||||
if not github_token:
|
|
||||||
raise ValueError("No GitHub token found for MCP token")
|
|
||||||
|
|
||||||
async with create_mcp_http_client() as client:
|
|
||||||
response = await client.get(
|
|
||||||
"https://api.github.com/user",
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {github_token}",
|
|
||||||
"Accept": "application/vnd.github.v3+json",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise ValueError(f"GitHub API error: {response.status_code}")
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
|
|
@ -7,11 +7,9 @@ Used for backwards compatibility testing with the new split AS/RS architecture.
|
||||||
NOTE: this is a simplified example for demonstration purposes.
|
NOTE: this is a simplified example for demonstration purposes.
|
||||||
This is not a production-ready implementation.
|
This is not a production-ready implementation.
|
||||||
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python -m mcp_simple_auth.legacy_as_server --port=8002
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
|
@ -19,97 +17,91 @@ import click
|
||||||
from pydantic import AnyHttpUrl, BaseModel
|
from pydantic import AnyHttpUrl, BaseModel
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import RedirectResponse, Response
|
from starlette.responses import Response
|
||||||
|
|
||||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
|
||||||
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
|
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
|
||||||
from mcp.server.fastmcp.server import FastMCP
|
from mcp.server.fastmcp.server import FastMCP
|
||||||
|
|
||||||
from .github_oauth_provider import GitHubOAuthProvider, GitHubOAuthSettings
|
from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ServerSettings(BaseModel):
|
class ServerSettings(BaseModel):
|
||||||
"""Settings for the simple GitHub MCP server."""
|
"""Settings for the simple auth MCP server."""
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
host: str = "localhost"
|
host: str = "localhost"
|
||||||
port: int = 8000
|
port: int = 8000
|
||||||
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000")
|
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000")
|
||||||
github_callback_path: str = "http://localhost:8000/github/callback"
|
auth_callback_path: str = "http://localhost:8000/login/callback"
|
||||||
|
|
||||||
|
|
||||||
class SimpleGitHubOAuthProvider(GitHubOAuthProvider):
|
class LegacySimpleOAuthProvider(SimpleOAuthProvider):
|
||||||
"""GitHub OAuth provider for legacy MCP server."""
|
"""Simple OAuth provider for legacy MCP server."""
|
||||||
|
|
||||||
def __init__(self, github_settings: GitHubOAuthSettings, github_callback_path: str):
|
def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str):
|
||||||
super().__init__(github_settings, github_callback_path)
|
super().__init__(auth_settings, auth_callback_path, server_url)
|
||||||
|
|
||||||
|
|
||||||
def create_simple_mcp_server(server_settings: ServerSettings, github_settings: GitHubOAuthSettings) -> FastMCP:
|
def create_simple_mcp_server(server_settings: ServerSettings, auth_settings: SimpleAuthSettings) -> FastMCP:
|
||||||
"""Create a simple FastMCP server with GitHub OAuth."""
|
"""Create a simple FastMCP server with simple authentication."""
|
||||||
oauth_provider = SimpleGitHubOAuthProvider(github_settings, server_settings.github_callback_path)
|
oauth_provider = LegacySimpleOAuthProvider(
|
||||||
|
auth_settings, server_settings.auth_callback_path, str(server_settings.server_url)
|
||||||
|
)
|
||||||
|
|
||||||
auth_settings = AuthSettings(
|
mcp_auth_settings = AuthSettings(
|
||||||
issuer_url=server_settings.server_url,
|
issuer_url=server_settings.server_url,
|
||||||
client_registration_options=ClientRegistrationOptions(
|
client_registration_options=ClientRegistrationOptions(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
valid_scopes=[github_settings.mcp_scope],
|
valid_scopes=[auth_settings.mcp_scope],
|
||||||
default_scopes=[github_settings.mcp_scope],
|
default_scopes=[auth_settings.mcp_scope],
|
||||||
),
|
),
|
||||||
required_scopes=[github_settings.mcp_scope],
|
required_scopes=[auth_settings.mcp_scope],
|
||||||
# No resource_server_url parameter in legacy mode
|
# No resource_server_url parameter in legacy mode
|
||||||
resource_server_url=None,
|
resource_server_url=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = FastMCP(
|
app = FastMCP(
|
||||||
name="Simple GitHub MCP Server",
|
name="Simple Auth MCP Server",
|
||||||
instructions="A simple MCP server with GitHub OAuth authentication",
|
instructions="A simple MCP server with simple credential authentication",
|
||||||
auth_server_provider=oauth_provider,
|
auth_server_provider=oauth_provider,
|
||||||
host=server_settings.host,
|
host=server_settings.host,
|
||||||
port=server_settings.port,
|
port=server_settings.port,
|
||||||
debug=True,
|
debug=True,
|
||||||
auth=auth_settings,
|
auth=mcp_auth_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.custom_route("/github/callback", methods=["GET"])
|
@app.custom_route("/login", methods=["GET"])
|
||||||
async def github_callback_handler(request: Request) -> Response:
|
async def login_page_handler(request: Request) -> Response:
|
||||||
"""Handle GitHub OAuth callback."""
|
"""Show login form."""
|
||||||
code = request.query_params.get("code")
|
|
||||||
state = request.query_params.get("state")
|
state = request.query_params.get("state")
|
||||||
|
if not state:
|
||||||
|
raise HTTPException(400, "Missing state parameter")
|
||||||
|
return await oauth_provider.get_login_page(state)
|
||||||
|
|
||||||
if not code or not state:
|
@app.custom_route("/login/callback", methods=["POST"])
|
||||||
raise HTTPException(400, "Missing code or state parameter")
|
async def login_callback_handler(request: Request) -> Response:
|
||||||
|
"""Handle simple authentication callback."""
|
||||||
redirect_uri = await oauth_provider.handle_github_callback(code, state)
|
return await oauth_provider.handle_login_callback(request)
|
||||||
return RedirectResponse(status_code=302, url=redirect_uri)
|
|
||||||
|
|
||||||
def get_github_token() -> str:
|
|
||||||
"""Get the GitHub token for the authenticated user."""
|
|
||||||
access_token = get_access_token()
|
|
||||||
if not access_token:
|
|
||||||
raise ValueError("Not authenticated")
|
|
||||||
|
|
||||||
# Get GitHub token from mapping
|
|
||||||
github_token = oauth_provider.token_mapping.get(access_token.token)
|
|
||||||
|
|
||||||
if not github_token:
|
|
||||||
raise ValueError("No GitHub token found for user")
|
|
||||||
|
|
||||||
return github_token
|
|
||||||
|
|
||||||
@app.tool()
|
@app.tool()
|
||||||
async def get_user_profile() -> dict[str, Any]:
|
async def get_time() -> dict[str, Any]:
|
||||||
"""Get the authenticated user's GitHub profile information.
|
|
||||||
|
|
||||||
This is the only tool in our simple example. It requires the 'user' scope.
|
|
||||||
"""
|
"""
|
||||||
access_token = get_access_token()
|
Get the current server time.
|
||||||
if not access_token:
|
|
||||||
raise ValueError("Not authenticated")
|
|
||||||
|
|
||||||
return await oauth_provider.get_github_user_info(access_token.token)
|
This tool demonstrates that system information can be protected
|
||||||
|
by OAuth authentication. User must be authenticated to access it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"current_time": now.isoformat(),
|
||||||
|
"timezone": "UTC", # Simplified for demo
|
||||||
|
"timestamp": now.timestamp(),
|
||||||
|
"formatted": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
@ -123,15 +115,10 @@ def create_simple_mcp_server(server_settings: ServerSettings, github_settings: G
|
||||||
help="Transport protocol to use ('sse' or 'streamable-http')",
|
help="Transport protocol to use ('sse' or 'streamable-http')",
|
||||||
)
|
)
|
||||||
def main(port: int, transport: Literal["sse", "streamable-http"]) -> int:
|
def main(port: int, transport: Literal["sse", "streamable-http"]) -> int:
|
||||||
"""Run the simple GitHub MCP server."""
|
"""Run the simple auth MCP server."""
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
# Load GitHub settings from environment variables
|
auth_settings = SimpleAuthSettings()
|
||||||
github_settings = GitHubOAuthSettings()
|
|
||||||
|
|
||||||
# Validate required fields
|
|
||||||
if not github_settings.github_client_id or not github_settings.github_client_secret:
|
|
||||||
raise ValueError("GitHub credentials not provided")
|
|
||||||
# Create server settings
|
# Create server settings
|
||||||
host = "localhost"
|
host = "localhost"
|
||||||
server_url = f"http://{host}:{port}"
|
server_url = f"http://{host}:{port}"
|
||||||
|
|
@ -139,11 +126,11 @@ def main(port: int, transport: Literal["sse", "streamable-http"]) -> int:
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
server_url=AnyHttpUrl(server_url),
|
server_url=AnyHttpUrl(server_url),
|
||||||
github_callback_path=f"{server_url}/github/callback",
|
auth_callback_path=f"{server_url}/login",
|
||||||
)
|
)
|
||||||
|
|
||||||
mcp_server = create_simple_mcp_server(server_settings, github_settings)
|
mcp_server = create_simple_mcp_server(server_settings, auth_settings)
|
||||||
logger.info(f"Starting server with {transport} transport")
|
logger.info(f"🚀 MCP Legacy Server running on {server_url}")
|
||||||
mcp_server.run(transport=transport)
|
mcp_server.run(transport=transport)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,18 @@ MCP Resource Server with Token Introspection.
|
||||||
This server validates tokens via Authorization Server introspection and serves MCP resources.
|
This server validates tokens via Authorization Server introspection and serves MCP resources.
|
||||||
Demonstrates RFC 9728 Protected Resource Metadata for AS/RS separation.
|
Demonstrates RFC 9728 Protected Resource Metadata for AS/RS separation.
|
||||||
|
|
||||||
Usage:
|
NOTE: this is a simplified example for demonstration purposes.
|
||||||
python -m mcp_simple_auth.server --port=8001 --auth-server=http://localhost:9000
|
This is not a production-ready implementation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import httpx
|
|
||||||
from pydantic import AnyHttpUrl
|
from pydantic import AnyHttpUrl
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
|
||||||
from mcp.server.auth.settings import AuthSettings
|
from mcp.server.auth.settings import AuthSettings
|
||||||
from mcp.server.fastmcp.server import FastMCP
|
from mcp.server.fastmcp.server import FastMCP
|
||||||
|
|
||||||
|
|
@ -38,7 +37,7 @@ class ResourceServerSettings(BaseSettings):
|
||||||
# Authorization Server settings
|
# Authorization Server settings
|
||||||
auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
|
auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
|
||||||
auth_server_introspection_endpoint: str = "http://localhost:9000/introspect"
|
auth_server_introspection_endpoint: str = "http://localhost:9000/introspect"
|
||||||
auth_server_github_user_endpoint: str = "http://localhost:9000/github/user"
|
# No user endpoint needed - we get user data from token introspection
|
||||||
|
|
||||||
# MCP settings
|
# MCP settings
|
||||||
mcp_scope: str = "user"
|
mcp_scope: str = "user"
|
||||||
|
|
@ -83,60 +82,22 @@ def create_resource_server(settings: ResourceServerSettings) -> FastMCP:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_github_user_data() -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Get GitHub user data via Authorization Server proxy endpoint.
|
|
||||||
|
|
||||||
This avoids exposing GitHub tokens to the Resource Server.
|
|
||||||
The Authorization Server handles the GitHub API call and returns the data.
|
|
||||||
"""
|
|
||||||
access_token = get_access_token()
|
|
||||||
if not access_token:
|
|
||||||
raise ValueError("Not authenticated")
|
|
||||||
|
|
||||||
# Call Authorization Server's GitHub proxy endpoint
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(
|
|
||||||
settings.auth_server_github_user_endpoint,
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {access_token.token}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise ValueError(f"GitHub user data fetch failed: {response.status_code} - {response.text}")
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
@app.tool()
|
@app.tool()
|
||||||
async def get_user_profile() -> dict[str, Any]:
|
async def get_time() -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get the authenticated user's GitHub profile information.
|
Get the current server time.
|
||||||
|
|
||||||
This tool requires the 'user' scope and demonstrates how Resource Servers
|
This tool demonstrates that system information can be protected
|
||||||
can access user data without directly handling GitHub tokens.
|
by OAuth authentication. User must be authenticated to access it.
|
||||||
"""
|
"""
|
||||||
return await get_github_user_data()
|
|
||||||
|
|
||||||
@app.tool()
|
now = datetime.datetime.now()
|
||||||
async def get_user_info() -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Get information about the currently authenticated user.
|
|
||||||
|
|
||||||
Returns token and scope information from the Resource Server's perspective.
|
|
||||||
"""
|
|
||||||
access_token = get_access_token()
|
|
||||||
if not access_token:
|
|
||||||
raise ValueError("Not authenticated")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"authenticated": True,
|
"current_time": now.isoformat(),
|
||||||
"client_id": access_token.client_id,
|
"timezone": "UTC", # Simplified for demo
|
||||||
"scopes": access_token.scopes,
|
"timestamp": now.timestamp(),
|
||||||
"token_expires_at": access_token.expires_at,
|
"formatted": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"token_type": "Bearer",
|
|
||||||
"resource_server": str(settings.server_url),
|
|
||||||
"authorization_server": str(settings.auth_server_url),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
@ -182,7 +143,6 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http
|
||||||
server_url=AnyHttpUrl(server_url),
|
server_url=AnyHttpUrl(server_url),
|
||||||
auth_server_url=auth_server_url,
|
auth_server_url=auth_server_url,
|
||||||
auth_server_introspection_endpoint=f"{auth_server}/introspect",
|
auth_server_introspection_endpoint=f"{auth_server}/introspect",
|
||||||
auth_server_github_user_endpoint=f"{auth_server}/github/user",
|
|
||||||
oauth_strict=oauth_strict,
|
oauth_strict=oauth_strict,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|
@ -193,24 +153,8 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http
|
||||||
try:
|
try:
|
||||||
mcp_server = create_resource_server(settings)
|
mcp_server = create_resource_server(settings)
|
||||||
|
|
||||||
logger.info("=" * 80)
|
logger.info(f"🚀 MCP Resource Server running on {settings.server_url}")
|
||||||
logger.info("📦 MCP RESOURCE SERVER")
|
logger.info(f"🔑 Using Authorization Server: {settings.auth_server_url}")
|
||||||
logger.info("=" * 80)
|
|
||||||
logger.info(f"🌐 Server URL: {settings.server_url}")
|
|
||||||
logger.info(f"🔑 Authorization Server: {settings.auth_server_url}")
|
|
||||||
logger.info("📋 Endpoints:")
|
|
||||||
logger.info(f" ┌─ Protected Resource Metadata: {settings.server_url}/.well-known/oauth-protected-resource")
|
|
||||||
mcp_path = "sse" if transport == "sse" else "mcp"
|
|
||||||
logger.info(f" ├─ MCP Protocol: {settings.server_url}/{mcp_path}")
|
|
||||||
logger.info(f" └─ Token Introspection: {settings.auth_server_introspection_endpoint}")
|
|
||||||
logger.info("")
|
|
||||||
logger.info("🛠️ Available Tools:")
|
|
||||||
logger.info(" ├─ get_user_profile() - Get GitHub user profile")
|
|
||||||
logger.info(" └─ get_user_info() - Get authentication status")
|
|
||||||
logger.info("")
|
|
||||||
logger.info("🔍 Tokens validated via Authorization Server introspection")
|
|
||||||
logger.info("📱 Clients discover Authorization Server via Protected Resource Metadata")
|
|
||||||
logger.info("=" * 80)
|
|
||||||
|
|
||||||
# Run the server - this should block and keep running
|
# Run the server - this should block and keep running
|
||||||
mcp_server.run(transport=transport)
|
mcp_server.run(transport=transport)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
"""
|
||||||
|
Simple OAuth provider for MCP servers.
|
||||||
|
|
||||||
|
This module contains a basic OAuth implementation using hardcoded user credentials
|
||||||
|
for demonstration purposes. No external authentication provider is required.
|
||||||
|
|
||||||
|
NOTE: this is a simplified example for demonstration purposes.
|
||||||
|
This is not a production-ready implementation.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import AnyHttpUrl
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import HTMLResponse, RedirectResponse, Response
|
||||||
|
|
||||||
|
from mcp.server.auth.provider import (
|
||||||
|
AccessToken,
|
||||||
|
AuthorizationCode,
|
||||||
|
AuthorizationParams,
|
||||||
|
OAuthAuthorizationServerProvider,
|
||||||
|
RefreshToken,
|
||||||
|
construct_redirect_uri,
|
||||||
|
)
|
||||||
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleAuthSettings(BaseSettings):
|
||||||
|
"""Simple OAuth settings for demo purposes."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="MCP_")
|
||||||
|
|
||||||
|
# Demo user credentials
|
||||||
|
demo_username: str = "demo_user"
|
||||||
|
demo_password: str = "demo_password"
|
||||||
|
|
||||||
|
# MCP OAuth scope
|
||||||
|
mcp_scope: str = "user"
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleOAuthProvider(OAuthAuthorizationServerProvider):
|
||||||
|
"""
|
||||||
|
Simple OAuth provider for demo purposes.
|
||||||
|
|
||||||
|
This provider handles the OAuth flow by:
|
||||||
|
1. Providing a simple login form for demo credentials
|
||||||
|
2. Issuing MCP tokens after successful authentication
|
||||||
|
3. Maintaining token state for introspection
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, settings: SimpleAuthSettings, auth_callback_url: str, server_url: str):
|
||||||
|
self.settings = settings
|
||||||
|
self.auth_callback_url = auth_callback_url
|
||||||
|
self.server_url = server_url
|
||||||
|
self.clients: dict[str, OAuthClientInformationFull] = {}
|
||||||
|
self.auth_codes: dict[str, AuthorizationCode] = {}
|
||||||
|
self.tokens: dict[str, AccessToken] = {}
|
||||||
|
self.state_mapping: dict[str, dict[str, str | None]] = {}
|
||||||
|
# Store authenticated user information
|
||||||
|
self.user_data: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||||
|
"""Get OAuth client information."""
|
||||||
|
return self.clients.get(client_id)
|
||||||
|
|
||||||
|
async def register_client(self, client_info: OAuthClientInformationFull):
|
||||||
|
"""Register a new OAuth client."""
|
||||||
|
self.clients[client_info.client_id] = client_info
|
||||||
|
|
||||||
|
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
||||||
|
"""Generate an authorization URL for simple login flow."""
|
||||||
|
state = params.state or secrets.token_hex(16)
|
||||||
|
|
||||||
|
# Store state mapping for callback
|
||||||
|
self.state_mapping[state] = {
|
||||||
|
"redirect_uri": str(params.redirect_uri),
|
||||||
|
"code_challenge": params.code_challenge,
|
||||||
|
"redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly),
|
||||||
|
"client_id": client.client_id,
|
||||||
|
"resource": params.resource, # RFC 8707
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build simple login URL that points to login page
|
||||||
|
auth_url = f"{self.auth_callback_url}" f"?state={state}" f"&client_id={client.client_id}"
|
||||||
|
|
||||||
|
return auth_url
|
||||||
|
|
||||||
|
async def get_login_page(self, state: str) -> HTMLResponse:
|
||||||
|
"""Generate login page HTML for the given state."""
|
||||||
|
if not state:
|
||||||
|
raise HTTPException(400, "Missing state parameter")
|
||||||
|
|
||||||
|
# Create simple login form HTML
|
||||||
|
html_content = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>MCP Demo Authentication</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: Arial, sans-serif; max-width: 500px; margin: 0 auto; padding: 20px; }}
|
||||||
|
.form-group {{ margin-bottom: 15px; }}
|
||||||
|
input {{ width: 100%; padding: 8px; margin-top: 5px; }}
|
||||||
|
button {{ background-color: #4CAF50; color: white; padding: 10px 15px; border: none; cursor: pointer; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>MCP Demo Authentication</h2>
|
||||||
|
<p>This is a simplified authentication demo. Use the demo credentials below:</p>
|
||||||
|
<p><strong>Username:</strong> demo_user<br>
|
||||||
|
<strong>Password:</strong> demo_password</p>
|
||||||
|
|
||||||
|
<form action="{self.server_url.rstrip('/')}/login/callback" method="post">
|
||||||
|
<input type="hidden" name="state" value="{state}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username:</label>
|
||||||
|
<input type="text" name="username" value="demo_user" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password:</label>
|
||||||
|
<input type="password" name="password" value="demo_password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Sign In</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return HTMLResponse(content=html_content)
|
||||||
|
|
||||||
|
async def handle_login_callback(self, request: Request) -> Response:
|
||||||
|
"""Handle login form submission callback."""
|
||||||
|
form = await request.form()
|
||||||
|
username = form.get("username")
|
||||||
|
password = form.get("password")
|
||||||
|
state = form.get("state")
|
||||||
|
|
||||||
|
if not username or not password or not state:
|
||||||
|
raise HTTPException(400, "Missing username, password, or state parameter")
|
||||||
|
|
||||||
|
# Ensure we have strings, not UploadFile objects
|
||||||
|
if not isinstance(username, str) or not isinstance(password, str) or not isinstance(state, str):
|
||||||
|
raise HTTPException(400, "Invalid parameter types")
|
||||||
|
|
||||||
|
redirect_uri = await self.handle_simple_callback(username, password, state)
|
||||||
|
return RedirectResponse(url=redirect_uri, status_code=302)
|
||||||
|
|
||||||
|
async def handle_simple_callback(self, username: str, password: str, state: str) -> str:
|
||||||
|
"""Handle simple authentication callback and return redirect URI."""
|
||||||
|
state_data = self.state_mapping.get(state)
|
||||||
|
if not state_data:
|
||||||
|
raise HTTPException(400, "Invalid state parameter")
|
||||||
|
|
||||||
|
redirect_uri = state_data["redirect_uri"]
|
||||||
|
code_challenge = state_data["code_challenge"]
|
||||||
|
redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True"
|
||||||
|
client_id = state_data["client_id"]
|
||||||
|
resource = state_data.get("resource") # RFC 8707
|
||||||
|
|
||||||
|
# These are required values from our own state mapping
|
||||||
|
assert redirect_uri is not None
|
||||||
|
assert code_challenge is not None
|
||||||
|
assert client_id is not None
|
||||||
|
|
||||||
|
# Validate demo credentials
|
||||||
|
if username != self.settings.demo_username or password != self.settings.demo_password:
|
||||||
|
raise HTTPException(401, "Invalid credentials")
|
||||||
|
|
||||||
|
# Create MCP authorization code
|
||||||
|
new_code = f"mcp_{secrets.token_hex(16)}"
|
||||||
|
auth_code = AuthorizationCode(
|
||||||
|
code=new_code,
|
||||||
|
client_id=client_id,
|
||||||
|
redirect_uri=AnyHttpUrl(redirect_uri),
|
||||||
|
redirect_uri_provided_explicitly=redirect_uri_provided_explicitly,
|
||||||
|
expires_at=time.time() + 300,
|
||||||
|
scopes=[self.settings.mcp_scope],
|
||||||
|
code_challenge=code_challenge,
|
||||||
|
resource=resource, # RFC 8707
|
||||||
|
)
|
||||||
|
self.auth_codes[new_code] = auth_code
|
||||||
|
|
||||||
|
# Store user data
|
||||||
|
self.user_data[username] = {
|
||||||
|
"username": username,
|
||||||
|
"user_id": f"user_{secrets.token_hex(8)}",
|
||||||
|
"authenticated_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
del self.state_mapping[state]
|
||||||
|
return construct_redirect_uri(redirect_uri, code=new_code, state=state)
|
||||||
|
|
||||||
|
async def load_authorization_code(
|
||||||
|
self, client: OAuthClientInformationFull, authorization_code: str
|
||||||
|
) -> AuthorizationCode | None:
|
||||||
|
"""Load an authorization code."""
|
||||||
|
return self.auth_codes.get(authorization_code)
|
||||||
|
|
||||||
|
async def exchange_authorization_code(
|
||||||
|
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
||||||
|
) -> OAuthToken:
|
||||||
|
"""Exchange authorization code for tokens."""
|
||||||
|
if authorization_code.code not in self.auth_codes:
|
||||||
|
raise ValueError("Invalid authorization code")
|
||||||
|
|
||||||
|
# Generate MCP access token
|
||||||
|
mcp_token = f"mcp_{secrets.token_hex(32)}"
|
||||||
|
|
||||||
|
# Store MCP token
|
||||||
|
self.tokens[mcp_token] = AccessToken(
|
||||||
|
token=mcp_token,
|
||||||
|
client_id=client.client_id,
|
||||||
|
scopes=authorization_code.scopes,
|
||||||
|
expires_at=int(time.time()) + 3600,
|
||||||
|
resource=authorization_code.resource, # RFC 8707
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store user data mapping for this token
|
||||||
|
self.user_data[mcp_token] = {
|
||||||
|
"username": self.settings.demo_username,
|
||||||
|
"user_id": f"user_{secrets.token_hex(8)}",
|
||||||
|
"authenticated_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
del self.auth_codes[authorization_code.code]
|
||||||
|
|
||||||
|
return OAuthToken(
|
||||||
|
access_token=mcp_token,
|
||||||
|
token_type="Bearer",
|
||||||
|
expires_in=3600,
|
||||||
|
scope=" ".join(authorization_code.scopes),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||||
|
"""Load and validate an access token."""
|
||||||
|
access_token = self.tokens.get(token)
|
||||||
|
if not access_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if expired
|
||||||
|
if access_token.expires_at and access_token.expires_at < time.time():
|
||||||
|
del self.tokens[token]
|
||||||
|
return None
|
||||||
|
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
|
||||||
|
"""Load a refresh token - not supported in this example."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def exchange_refresh_token(
|
||||||
|
self,
|
||||||
|
client: OAuthClientInformationFull,
|
||||||
|
refresh_token: RefreshToken,
|
||||||
|
scopes: list[str],
|
||||||
|
) -> OAuthToken:
|
||||||
|
"""Exchange refresh token - not supported in this example."""
|
||||||
|
raise NotImplementedError("Refresh tokens not supported")
|
||||||
|
|
||||||
|
async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None:
|
||||||
|
"""Revoke a token."""
|
||||||
|
if token in self.tokens:
|
||||||
|
del self.tokens[token]
|
||||||
Loading…
Reference in a new issue