fix oauth session
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
MCP Auth Toolkit - OAuth 2.1 + Authorization for Model Context Protocol Servers
|
||||||
|
Integrated with Clerk Authentication
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .middleware import (
|
||||||
|
AuthContext,
|
||||||
|
FastMCPAuthWrapper,
|
||||||
|
MCPAuthMiddleware,
|
||||||
|
auth_required,
|
||||||
|
)
|
||||||
|
from .oauth import OAuthConfig, OAuthProvider
|
||||||
|
from .policy import PolicyEngine, ToolPolicy, create_default_policies
|
||||||
|
from .storage import PersistentStorage
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
__all__ = [
|
||||||
|
"OAuthProvider",
|
||||||
|
"OAuthConfig",
|
||||||
|
"AuthContext",
|
||||||
|
"auth_required",
|
||||||
|
"create_default_policies",
|
||||||
|
"MCPAuthMiddleware",
|
||||||
|
"FastMCPAuthWrapper",
|
||||||
|
"PolicyEngine",
|
||||||
|
"ToolPolicy",
|
||||||
|
"PersistentStorage",
|
||||||
|
]
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Clerk OAuth configuration for MCP Auth Toolkit
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from .oauth import OAuthConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_clerk_oauth_config() -> OAuthConfig:
|
||||||
|
"""Create OAuth configuration for Clerk integration"""
|
||||||
|
|
||||||
|
# Get Clerk configuration from environment
|
||||||
|
clerk_domain = os.getenv("CLERK_DOMAIN", "accounts.yargimcp.com")
|
||||||
|
clerk_publishable_key = os.getenv("CLERK_PUBLISHABLE_KEY")
|
||||||
|
clerk_secret_key = os.getenv("CLERK_SECRET_KEY")
|
||||||
|
|
||||||
|
if not clerk_publishable_key or not clerk_secret_key:
|
||||||
|
raise ValueError("CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY are required")
|
||||||
|
|
||||||
|
# Determine if custom domain or standard Clerk domain
|
||||||
|
if '.' in clerk_domain and not clerk_domain.endswith('.accounts.dev'):
|
||||||
|
# Custom domain like accounts.yargimcp.com
|
||||||
|
base_url = f"https://{clerk_domain}"
|
||||||
|
issuer = f"https://{clerk_domain}"
|
||||||
|
else:
|
||||||
|
# Standard Clerk subdomain
|
||||||
|
base_url = f"https://{clerk_domain}.accounts.dev"
|
||||||
|
issuer = f"https://{clerk_domain}.accounts.dev"
|
||||||
|
|
||||||
|
config = OAuthConfig(
|
||||||
|
client_id=clerk_publishable_key,
|
||||||
|
client_secret=clerk_secret_key,
|
||||||
|
authorization_endpoint=f"{base_url}/oauth/authorize",
|
||||||
|
token_endpoint=f"{base_url}/oauth/token",
|
||||||
|
jwks_uri=f"{base_url}/.well-known/jwks.json",
|
||||||
|
issuer=issuer,
|
||||||
|
scopes=["mcp:tools:read", "mcp:tools:write", "openid", "profile", "email"]
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Created Clerk OAuth config for domain: {clerk_domain}")
|
||||||
|
logger.debug(f"Authorization endpoint: {config.authorization_endpoint}")
|
||||||
|
logger.debug(f"Token endpoint: {config.token_endpoint}")
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwt_secret() -> str:
|
||||||
|
"""Get JWT secret for token signing"""
|
||||||
|
jwt_secret = os.getenv("JWT_SECRET_KEY")
|
||||||
|
|
||||||
|
if not jwt_secret:
|
||||||
|
raise ValueError("JWT_SECRET_KEY environment variable is required")
|
||||||
|
|
||||||
|
return jwt_secret
|
||||||
|
|
||||||
|
|
||||||
|
def create_mcp_server_config():
|
||||||
|
"""Create complete MCP server configuration for Clerk integration"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
oauth_config = create_clerk_oauth_config()
|
||||||
|
jwt_secret = get_jwt_secret()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"oauth_config": oauth_config,
|
||||||
|
"jwt_secret": jwt_secret,
|
||||||
|
"base_url": os.getenv("BASE_URL", "https://yargi-mcp.fly.dev"),
|
||||||
|
"auth_enabled": os.getenv("ENABLE_AUTH", "true").lower() == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create MCP server config: {e}")
|
||||||
|
raise
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""
|
||||||
|
MCP server middleware for OAuth authentication and authorization
|
||||||
|
"""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
FASTMCP_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
FASTMCP_AVAILABLE = False
|
||||||
|
FastMCP = None
|
||||||
|
logger.warning("FastMCP not available, some features will be disabled")
|
||||||
|
|
||||||
|
from .oauth import OAuthProvider
|
||||||
|
from .policy import PolicyEngine
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AuthContext:
|
||||||
|
"""Authentication context passed to MCP tools"""
|
||||||
|
|
||||||
|
user_id: str
|
||||||
|
scopes: list[str]
|
||||||
|
claims: dict[str, Any]
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class MCPAuthMiddleware:
|
||||||
|
"""Authentication middleware for MCP servers"""
|
||||||
|
|
||||||
|
def __init__(self, oauth_provider: OAuthProvider, policy_engine: PolicyEngine):
|
||||||
|
self.oauth_provider = oauth_provider
|
||||||
|
self.policy_engine = policy_engine
|
||||||
|
|
||||||
|
def authenticate_request(self, authorization_header: str) -> AuthContext | None:
|
||||||
|
"""Extract and validate auth token from request"""
|
||||||
|
|
||||||
|
if not authorization_header:
|
||||||
|
logger.debug("No authorization header provided")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not authorization_header.startswith("Bearer "):
|
||||||
|
logger.debug("Authorization header does not start with 'Bearer '")
|
||||||
|
return None
|
||||||
|
|
||||||
|
token = authorization_header[7:] # Remove 'Bearer ' prefix
|
||||||
|
|
||||||
|
token_info = self.oauth_provider.introspect_token(token)
|
||||||
|
|
||||||
|
if not token_info.get("active"):
|
||||||
|
logger.warning("Token is not active")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.debug(f"Authenticated user: {token_info.get('sub', 'unknown')}")
|
||||||
|
|
||||||
|
return AuthContext(
|
||||||
|
user_id=token_info.get("sub", "unknown"),
|
||||||
|
scopes=token_info.get("mcp_tool_scopes", []),
|
||||||
|
claims=token_info,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_tool_call(
|
||||||
|
self, tool_name: str, auth_context: AuthContext
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""Check if user can call the specified tool"""
|
||||||
|
|
||||||
|
return self.policy_engine.authorize_tool_call(
|
||||||
|
tool_name=tool_name,
|
||||||
|
user_scopes=auth_context.scopes,
|
||||||
|
user_claims=auth_context.claims,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def auth_required(
|
||||||
|
oauth_provider: OAuthProvider,
|
||||||
|
policy_engine: PolicyEngine,
|
||||||
|
tool_name: str | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Decorator to require authentication for MCP tool functions
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@auth_required(oauth_provider, policy_engine, "search_yargitay")
|
||||||
|
def my_tool_function(context: AuthContext, ...):
|
||||||
|
pass
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
middleware = MCPAuthMiddleware(oauth_provider, policy_engine)
|
||||||
|
|
||||||
|
@functools.wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
# Extract authorization header from kwargs
|
||||||
|
auth_header = kwargs.pop("authorization", None)
|
||||||
|
|
||||||
|
# Also check in args if it's a Request object
|
||||||
|
if not auth_header and args:
|
||||||
|
for arg in args:
|
||||||
|
if hasattr(arg, 'headers'):
|
||||||
|
auth_header = arg.headers.get("Authorization")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not auth_header:
|
||||||
|
logger.warning(f"No authorization header for tool '{tool_name or func.__name__}'")
|
||||||
|
raise PermissionError("Authorization header required")
|
||||||
|
|
||||||
|
auth_context = middleware.authenticate_request(auth_header)
|
||||||
|
|
||||||
|
if not auth_context:
|
||||||
|
logger.warning(f"Authentication failed for tool '{tool_name or func.__name__}'")
|
||||||
|
raise PermissionError("Invalid or expired token")
|
||||||
|
|
||||||
|
actual_tool_name = tool_name or func.__name__
|
||||||
|
|
||||||
|
authorized, reason = middleware.authorize_tool_call(
|
||||||
|
actual_tool_name, auth_context
|
||||||
|
)
|
||||||
|
|
||||||
|
if not authorized:
|
||||||
|
logger.warning(f"Authorization failed for tool '{actual_tool_name}': {reason}")
|
||||||
|
raise PermissionError(f"Access denied: {reason}")
|
||||||
|
|
||||||
|
# Add auth context to function call
|
||||||
|
return await func(auth_context, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
class FastMCPAuthWrapper:
|
||||||
|
"""Wrapper for FastMCP servers to add authentication"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
mcp_server: "FastMCP",
|
||||||
|
oauth_provider: OAuthProvider,
|
||||||
|
policy_engine: PolicyEngine,
|
||||||
|
):
|
||||||
|
if not FASTMCP_AVAILABLE:
|
||||||
|
raise ImportError("FastMCP is required for FastMCPAuthWrapper")
|
||||||
|
|
||||||
|
self.mcp_server = mcp_server
|
||||||
|
self.middleware = MCPAuthMiddleware(oauth_provider, policy_engine)
|
||||||
|
self.oauth_provider = oauth_provider
|
||||||
|
logger.info("Initializing FastMCP authentication wrapper")
|
||||||
|
self._wrap_tools()
|
||||||
|
|
||||||
|
def _wrap_tools(self):
|
||||||
|
"""Wrap all existing tools with auth middleware"""
|
||||||
|
|
||||||
|
# Try different FastMCP tool storage locations
|
||||||
|
tool_registry = None
|
||||||
|
|
||||||
|
if hasattr(self.mcp_server, '_tools'):
|
||||||
|
tool_registry = self.mcp_server._tools
|
||||||
|
elif hasattr(self.mcp_server, 'tools'):
|
||||||
|
tool_registry = self.mcp_server.tools
|
||||||
|
elif hasattr(self.mcp_server, '_tool_registry'):
|
||||||
|
tool_registry = self.mcp_server._tool_registry
|
||||||
|
elif hasattr(self.mcp_server, '_handlers') and hasattr(self.mcp_server._handlers, 'tools'):
|
||||||
|
tool_registry = self.mcp_server._handlers.tools
|
||||||
|
|
||||||
|
if not tool_registry:
|
||||||
|
logger.warning("FastMCP server tool registry not found, tools will not be automatically wrapped")
|
||||||
|
logger.debug(f"Available server attributes: {dir(self.mcp_server)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug(f"Found tool registry with {len(tool_registry)} tools")
|
||||||
|
original_tools = dict(tool_registry)
|
||||||
|
wrapped_count = 0
|
||||||
|
|
||||||
|
for tool_name, tool_func in original_tools.items():
|
||||||
|
try:
|
||||||
|
wrapped_func = self._create_auth_wrapper(tool_name, tool_func)
|
||||||
|
tool_registry[tool_name] = wrapped_func
|
||||||
|
wrapped_count += 1
|
||||||
|
logger.debug(f"Wrapped tool: {tool_name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to wrap tool {tool_name}: {e}")
|
||||||
|
|
||||||
|
logger.info(f"Successfully wrapped {wrapped_count} tools with authentication")
|
||||||
|
|
||||||
|
def _create_auth_wrapper(self, tool_name: str, original_func: Callable) -> Callable:
|
||||||
|
"""Create auth wrapper for a specific tool"""
|
||||||
|
|
||||||
|
@functools.wraps(original_func)
|
||||||
|
async def auth_wrapper(*args, **kwargs):
|
||||||
|
# Extract authorization from various sources
|
||||||
|
auth_header = None
|
||||||
|
|
||||||
|
# Check kwargs first
|
||||||
|
auth_header = kwargs.pop("authorization", None)
|
||||||
|
|
||||||
|
# Check if first argument is a Request object
|
||||||
|
if not auth_header and args:
|
||||||
|
first_arg = args[0]
|
||||||
|
if hasattr(first_arg, 'headers'):
|
||||||
|
auth_header = first_arg.headers.get("Authorization")
|
||||||
|
|
||||||
|
if not auth_header:
|
||||||
|
logger.warning(f"No authorization header for tool '{tool_name}'")
|
||||||
|
raise PermissionError("Authorization required")
|
||||||
|
|
||||||
|
auth_context = self.middleware.authenticate_request(auth_header)
|
||||||
|
|
||||||
|
if not auth_context:
|
||||||
|
logger.warning(f"Authentication failed for tool '{tool_name}'")
|
||||||
|
raise PermissionError("Invalid token")
|
||||||
|
|
||||||
|
authorized, reason = self.middleware.authorize_tool_call(
|
||||||
|
tool_name, auth_context
|
||||||
|
)
|
||||||
|
|
||||||
|
if not authorized:
|
||||||
|
logger.warning(f"Authorization failed for tool '{tool_name}': {reason}")
|
||||||
|
raise PermissionError(f"Access denied: {reason}")
|
||||||
|
|
||||||
|
# Add auth context to kwargs
|
||||||
|
kwargs["auth_context"] = auth_context
|
||||||
|
logger.debug(f"Calling tool '{tool_name}' for user {auth_context.user_id}")
|
||||||
|
|
||||||
|
return await original_func(*args, **kwargs)
|
||||||
|
|
||||||
|
return auth_wrapper
|
||||||
|
|
||||||
|
def add_oauth_endpoints(self):
|
||||||
|
"""Add OAuth endpoints to the MCP server"""
|
||||||
|
|
||||||
|
@self.mcp_server.tool(
|
||||||
|
description="Initiate OAuth 2.1 authorization flow with PKCE",
|
||||||
|
annotations={"readOnlyHint": True, "idempotentHint": False}
|
||||||
|
)
|
||||||
|
async def oauth_authorize(redirect_uri: str, scopes: Optional[str] = None):
|
||||||
|
"""OAuth authorization endpoint"""
|
||||||
|
scope_list = scopes.split(" ") if scopes else None
|
||||||
|
auth_url, pkce = self.oauth_provider.generate_authorization_url(
|
||||||
|
redirect_uri=redirect_uri, scopes=scope_list
|
||||||
|
)
|
||||||
|
logger.info(f"Generated authorization URL for redirect_uri: {redirect_uri}")
|
||||||
|
return {
|
||||||
|
"authorization_url": auth_url,
|
||||||
|
"code_verifier": pkce.verifier, # For PKCE flow
|
||||||
|
"code_challenge": pkce.challenge,
|
||||||
|
"instructions": "Use the authorization_url to complete OAuth flow, then exchange the returned code using oauth_token tool"
|
||||||
|
}
|
||||||
|
|
||||||
|
@self.mcp_server.tool(
|
||||||
|
description="Exchange OAuth authorization code for access token",
|
||||||
|
annotations={"readOnlyHint": False, "idempotentHint": False}
|
||||||
|
)
|
||||||
|
async def oauth_token(
|
||||||
|
code: str,
|
||||||
|
state: str,
|
||||||
|
redirect_uri: str
|
||||||
|
):
|
||||||
|
"""OAuth token exchange endpoint"""
|
||||||
|
try:
|
||||||
|
result = await self.oauth_provider.exchange_code_for_token(
|
||||||
|
code=code, state=state, redirect_uri=redirect_uri
|
||||||
|
)
|
||||||
|
logger.info("Successfully exchanged authorization code for token")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token exchange failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@self.mcp_server.tool(
|
||||||
|
description="Validate and introspect OAuth access token",
|
||||||
|
annotations={"readOnlyHint": True, "idempotentHint": True}
|
||||||
|
)
|
||||||
|
async def oauth_introspect(token: str):
|
||||||
|
"""Token introspection endpoint"""
|
||||||
|
result = self.oauth_provider.introspect_token(token)
|
||||||
|
logger.debug(f"Token introspection: active={result.get('active', False)}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
@self.mcp_server.tool(
|
||||||
|
description="Revoke OAuth access token",
|
||||||
|
annotations={"readOnlyHint": False, "idempotentHint": False}
|
||||||
|
)
|
||||||
|
async def oauth_revoke(token: str):
|
||||||
|
"""Token revocation endpoint"""
|
||||||
|
success = self.oauth_provider.revoke_token(token)
|
||||||
|
logger.info(f"Token revocation: success={success}")
|
||||||
|
return {"revoked": success}
|
||||||
|
|
||||||
|
@self.mcp_server.tool(
|
||||||
|
description="Get list of tools available to authenticated user",
|
||||||
|
annotations={"readOnlyHint": True, "idempotentHint": True}
|
||||||
|
)
|
||||||
|
async def oauth_user_tools(authorization: str):
|
||||||
|
"""Get user's allowed tools based on scopes"""
|
||||||
|
auth_context = self.middleware.authenticate_request(authorization)
|
||||||
|
if not auth_context:
|
||||||
|
raise PermissionError("Invalid token")
|
||||||
|
|
||||||
|
allowed_patterns = self.middleware.policy_engine.get_allowed_tools(auth_context.scopes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": auth_context.user_id,
|
||||||
|
"scopes": auth_context.scopes,
|
||||||
|
"allowed_tool_patterns": allowed_patterns,
|
||||||
|
"message": "Use these patterns to determine which tools you can access"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Added OAuth endpoints: oauth_authorize, oauth_token, oauth_introspect, oauth_revoke, oauth_user_tools")
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""
|
||||||
|
OAuth 2.1 + PKCE implementation for MCP servers with Clerk integration
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
from jwt.exceptions import PyJWTError, InvalidTokenError
|
||||||
|
|
||||||
|
from .storage import PersistentStorage
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OAuthConfig:
|
||||||
|
"""OAuth provider configuration for Clerk"""
|
||||||
|
|
||||||
|
client_id: str
|
||||||
|
client_secret: str
|
||||||
|
authorization_endpoint: str
|
||||||
|
token_endpoint: str
|
||||||
|
jwks_uri: str | None = None
|
||||||
|
issuer: str = "mcp-auth"
|
||||||
|
scopes: list[str] = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.scopes is None:
|
||||||
|
self.scopes = ["mcp:tools:read", "mcp:tools:write"]
|
||||||
|
|
||||||
|
|
||||||
|
class PKCEChallenge:
|
||||||
|
"""PKCE challenge/verifier pair for OAuth 2.1"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.verifier = (
|
||||||
|
base64.urlsafe_b64encode(secrets.token_bytes(32))
|
||||||
|
.decode("utf-8")
|
||||||
|
.rstrip("=")
|
||||||
|
)
|
||||||
|
|
||||||
|
challenge_bytes = hashlib.sha256(self.verifier.encode("utf-8")).digest()
|
||||||
|
self.challenge = (
|
||||||
|
base64.urlsafe_b64encode(challenge_bytes).decode("utf-8").rstrip("=")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthProvider:
|
||||||
|
"""OAuth 2.1 provider with PKCE support and Clerk integration"""
|
||||||
|
|
||||||
|
def __init__(self, config: OAuthConfig, jwt_secret: str):
|
||||||
|
self.config = config
|
||||||
|
self.jwt_secret = jwt_secret
|
||||||
|
# Use persistent storage instead of memory
|
||||||
|
self.storage = PersistentStorage()
|
||||||
|
logger.info("OAuth provider initialized with persistent storage")
|
||||||
|
|
||||||
|
def generate_authorization_url(
|
||||||
|
self,
|
||||||
|
redirect_uri: str,
|
||||||
|
state: str | None = None,
|
||||||
|
scopes: list[str] | None = None,
|
||||||
|
) -> tuple[str, PKCEChallenge]:
|
||||||
|
"""Generate OAuth authorization URL with PKCE for Clerk"""
|
||||||
|
|
||||||
|
pkce = PKCEChallenge()
|
||||||
|
session_id = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
if state is None:
|
||||||
|
state = secrets.token_urlsafe(16)
|
||||||
|
|
||||||
|
if scopes is None:
|
||||||
|
scopes = self.config.scopes
|
||||||
|
|
||||||
|
# Store session data with expiration
|
||||||
|
session_data = {
|
||||||
|
"pkce_verifier": pkce.verifier,
|
||||||
|
"state": state,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"scopes": scopes,
|
||||||
|
"created_at": time.time(),
|
||||||
|
"expires_at": (datetime.utcnow() + timedelta(minutes=10)).timestamp(),
|
||||||
|
}
|
||||||
|
self.storage.set_session(session_id, session_data)
|
||||||
|
|
||||||
|
# Build Clerk OAuth URL with PKCE
|
||||||
|
params = {
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": self.config.client_id,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"scope": " ".join(scopes),
|
||||||
|
"state": f"{state}:{session_id}", # Combine state with session ID
|
||||||
|
"code_challenge": pkce.challenge,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_url = f"{self.config.authorization_endpoint}?{urlencode(params)}"
|
||||||
|
logger.info(f"Generated OAuth URL with session {session_id[:8]}...")
|
||||||
|
return auth_url, pkce
|
||||||
|
|
||||||
|
async def exchange_code_for_token(
|
||||||
|
self, code: str, state: str, redirect_uri: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Exchange authorization code for access token with Clerk"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
original_state, session_id = state.split(":", 1)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Invalid state format: {state}")
|
||||||
|
raise ValueError("Invalid state format") from e
|
||||||
|
|
||||||
|
session = self.storage.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
logger.error(f"Session {session_id} not found")
|
||||||
|
raise ValueError("Invalid session")
|
||||||
|
|
||||||
|
# Check session expiration
|
||||||
|
if datetime.utcnow().timestamp() > session.get("expires_at", 0):
|
||||||
|
self.storage.delete_session(session_id)
|
||||||
|
logger.error(f"Session {session_id} expired")
|
||||||
|
raise ValueError("Session expired")
|
||||||
|
|
||||||
|
if session["state"] != original_state:
|
||||||
|
logger.error(f"State mismatch: expected {session['state']}, got {original_state}")
|
||||||
|
raise ValueError("State mismatch")
|
||||||
|
|
||||||
|
if session["redirect_uri"] != redirect_uri:
|
||||||
|
logger.error(f"Redirect URI mismatch: expected {session['redirect_uri']}, got {redirect_uri}")
|
||||||
|
raise ValueError("Redirect URI mismatch")
|
||||||
|
|
||||||
|
# Prepare token exchange request for Clerk
|
||||||
|
token_data = {
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": self.config.client_id,
|
||||||
|
"client_secret": self.config.client_secret,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"code_verifier": session["pkce_verifier"],
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Exchanging code with Clerk for session {session_id[:8]}...")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
self.config.token_endpoint,
|
||||||
|
data=token_data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"Clerk token exchange failed: {response.status_code} - {response.text}")
|
||||||
|
raise ValueError(f"Token exchange failed: {response.text}")
|
||||||
|
|
||||||
|
token_response = response.json()
|
||||||
|
logger.info("Successfully exchanged code for Clerk token")
|
||||||
|
|
||||||
|
# Create MCP-scoped JWT token
|
||||||
|
access_token = self._create_mcp_token(
|
||||||
|
session["scopes"], token_response.get("access_token"), session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store token for introspection
|
||||||
|
token_id = secrets.token_urlsafe(16)
|
||||||
|
token_data = {
|
||||||
|
"access_token": access_token,
|
||||||
|
"scopes": session["scopes"],
|
||||||
|
"created_at": time.time(),
|
||||||
|
"expires_at": (datetime.utcnow() + timedelta(hours=1)).timestamp(),
|
||||||
|
"session_id": session_id,
|
||||||
|
"clerk_token": token_response.get("access_token"),
|
||||||
|
}
|
||||||
|
self.storage.set_token(token_id, token_data)
|
||||||
|
|
||||||
|
# Clean up session
|
||||||
|
self.storage.delete_session(session_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"scope": " ".join(session["scopes"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _create_mcp_token(
|
||||||
|
self, scopes: list[str], upstream_token: str, session_id: str
|
||||||
|
) -> str:
|
||||||
|
"""Create MCP-scoped JWT token with Clerk token embedded"""
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
payload = {
|
||||||
|
"iss": self.config.issuer,
|
||||||
|
"sub": session_id,
|
||||||
|
"aud": "mcp-server",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + 3600, # 1 hour expiration
|
||||||
|
"mcp_tool_scopes": scopes,
|
||||||
|
"upstream_token": upstream_token,
|
||||||
|
"clerk_integration": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(payload, self.jwt_secret, algorithm="HS256")
|
||||||
|
|
||||||
|
def introspect_token(self, token: str) -> dict[str, Any]:
|
||||||
|
"""Introspect and validate MCP token"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
|
||||||
|
|
||||||
|
# Check if token is expired
|
||||||
|
if payload.get("exp", 0) < time.time():
|
||||||
|
return {"active": False, "error": "token_expired"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"active": True,
|
||||||
|
"sub": payload.get("sub"),
|
||||||
|
"aud": payload.get("aud"),
|
||||||
|
"iss": payload.get("iss"),
|
||||||
|
"exp": payload.get("exp"),
|
||||||
|
"iat": payload.get("iat"),
|
||||||
|
"mcp_tool_scopes": payload.get("mcp_tool_scopes", []),
|
||||||
|
"upstream_token": payload.get("upstream_token"),
|
||||||
|
"clerk_integration": payload.get("clerk_integration", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
except PyJWTError as e:
|
||||||
|
logger.warning(f"Token validation failed: {e}")
|
||||||
|
return {"active": False, "error": "invalid_token"}
|
||||||
|
|
||||||
|
def revoke_token(self, token: str) -> bool:
|
||||||
|
"""Revoke a token"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
|
||||||
|
session_id = payload.get("sub")
|
||||||
|
|
||||||
|
# Remove all tokens associated with this session
|
||||||
|
all_tokens = self.storage.get_tokens()
|
||||||
|
tokens_to_remove = [
|
||||||
|
token_id
|
||||||
|
for token_id, token_data in all_tokens.items()
|
||||||
|
if token_data.get("session_id") == session_id
|
||||||
|
]
|
||||||
|
|
||||||
|
for token_id in tokens_to_remove:
|
||||||
|
self.storage.delete_token(token_id)
|
||||||
|
|
||||||
|
logger.info(f"Revoked {len(tokens_to_remove)} tokens for session {session_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except InvalidTokenError as e:
|
||||||
|
logger.warning(f"Token revocation failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def cleanup_expired_sessions(self):
|
||||||
|
"""Clean up expired sessions and tokens"""
|
||||||
|
# This is now handled automatically by persistent storage
|
||||||
|
self.storage.cleanup_expired_sessions()
|
||||||
|
logger.debug("Cleanup completed via persistent storage")
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
Authorization policy engine for MCP tools
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyAction(Enum):
|
||||||
|
ALLOW = "allow"
|
||||||
|
DENY = "deny"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolPolicy:
|
||||||
|
"""Policy rule for MCP tool access"""
|
||||||
|
|
||||||
|
tool_pattern: str # regex pattern for tool names
|
||||||
|
required_scopes: list[str]
|
||||||
|
action: PolicyAction = PolicyAction.ALLOW
|
||||||
|
conditions: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def matches_tool(self, tool_name: str) -> bool:
|
||||||
|
"""Check if the policy applies to given tool"""
|
||||||
|
return bool(re.match(self.tool_pattern, tool_name))
|
||||||
|
|
||||||
|
def evaluate_scopes(self, user_scopes: list[str]) -> bool:
|
||||||
|
"""Check if user has required scopes"""
|
||||||
|
return all(scope in user_scopes for scope in self.required_scopes)
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyEngine:
|
||||||
|
"""Authorization policy engine for Turkish legal database tools"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.policies: list[ToolPolicy] = []
|
||||||
|
self.default_action = PolicyAction.DENY
|
||||||
|
|
||||||
|
def add_policy(self, policy: ToolPolicy):
|
||||||
|
"""Add a policy rule"""
|
||||||
|
self.policies.append(policy)
|
||||||
|
logger.debug(f"Added policy: {policy.tool_pattern} -> {policy.required_scopes}")
|
||||||
|
|
||||||
|
def add_tool_scope_policy(
|
||||||
|
self,
|
||||||
|
tool_pattern: str,
|
||||||
|
required_scopes: str | list[str],
|
||||||
|
action: PolicyAction = PolicyAction.ALLOW,
|
||||||
|
):
|
||||||
|
"""Convenience method to add tool-scope policy"""
|
||||||
|
if isinstance(required_scopes, str):
|
||||||
|
required_scopes = [required_scopes]
|
||||||
|
|
||||||
|
policy = ToolPolicy(
|
||||||
|
tool_pattern=tool_pattern, required_scopes=required_scopes, action=action
|
||||||
|
)
|
||||||
|
self.add_policy(policy)
|
||||||
|
|
||||||
|
def authorize_tool_call(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
user_scopes: list[str],
|
||||||
|
user_claims: dict[str, Any] | None = None,
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""
|
||||||
|
Authorize a tool call
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(authorized: bool, reason: Optional[str])
|
||||||
|
"""
|
||||||
|
|
||||||
|
logger.debug(f"Authorizing tool '{tool_name}' for user with scopes: {user_scopes}")
|
||||||
|
|
||||||
|
matching_policies = [
|
||||||
|
policy for policy in self.policies if policy.matches_tool(tool_name)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not matching_policies:
|
||||||
|
if self.default_action == PolicyAction.ALLOW:
|
||||||
|
logger.debug(f"No policies found for '{tool_name}', allowing by default")
|
||||||
|
return True, None
|
||||||
|
else:
|
||||||
|
logger.warning(f"No policies found for '{tool_name}', denying by default")
|
||||||
|
return False, f"No policy found for tool '{tool_name}', default deny"
|
||||||
|
|
||||||
|
# Check for explicit deny policies first
|
||||||
|
for policy in matching_policies:
|
||||||
|
if policy.action == PolicyAction.DENY:
|
||||||
|
if policy.evaluate_scopes(user_scopes):
|
||||||
|
logger.warning(f"Explicit deny policy matched for '{tool_name}'")
|
||||||
|
return False, f"Explicit deny policy for tool '{tool_name}'"
|
||||||
|
|
||||||
|
# Check allow policies
|
||||||
|
allow_policies = [
|
||||||
|
p for p in matching_policies if p.action == PolicyAction.ALLOW
|
||||||
|
]
|
||||||
|
|
||||||
|
if not allow_policies:
|
||||||
|
logger.warning(f"No allow policies found for '{tool_name}'")
|
||||||
|
return False, f"No allow policies found for tool '{tool_name}'"
|
||||||
|
|
||||||
|
for policy in allow_policies:
|
||||||
|
if policy.evaluate_scopes(user_scopes):
|
||||||
|
if self._evaluate_conditions(policy.conditions, user_claims):
|
||||||
|
logger.debug(f"Authorization granted for '{tool_name}'")
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
logger.warning(f"Insufficient scopes for '{tool_name}'. Required: {[p.required_scopes for p in allow_policies]}, User has: {user_scopes}")
|
||||||
|
return False, f"Insufficient scopes for tool '{tool_name}'"
|
||||||
|
|
||||||
|
def _evaluate_conditions(
|
||||||
|
self,
|
||||||
|
conditions: dict[str, Any] | None,
|
||||||
|
user_claims: dict[str, Any] | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Evaluate additional policy conditions"""
|
||||||
|
|
||||||
|
if not conditions:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not user_claims:
|
||||||
|
logger.debug("No user claims provided, conditions evaluation failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
for key, expected_value in conditions.items():
|
||||||
|
user_value = user_claims.get(key)
|
||||||
|
|
||||||
|
if isinstance(expected_value, list):
|
||||||
|
if user_value not in expected_value:
|
||||||
|
logger.debug(f"Condition failed: {key} = {user_value} not in {expected_value}")
|
||||||
|
return False
|
||||||
|
elif user_value != expected_value:
|
||||||
|
logger.debug(f"Condition failed: {key} = {user_value} != {expected_value}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_allowed_tools(self, user_scopes: list[str]) -> list[str]:
|
||||||
|
"""Get list of tool patterns user is allowed to call"""
|
||||||
|
|
||||||
|
allowed_tools = []
|
||||||
|
|
||||||
|
for policy in self.policies:
|
||||||
|
if policy.action == PolicyAction.ALLOW and policy.evaluate_scopes(
|
||||||
|
user_scopes
|
||||||
|
):
|
||||||
|
allowed_tools.append(policy.tool_pattern)
|
||||||
|
|
||||||
|
return allowed_tools
|
||||||
|
|
||||||
|
|
||||||
|
def create_turkish_legal_policies() -> PolicyEngine:
|
||||||
|
"""Create policy set for Turkish legal database MCP server"""
|
||||||
|
|
||||||
|
engine = PolicyEngine()
|
||||||
|
|
||||||
|
# Administrative tools (full access)
|
||||||
|
engine.add_tool_scope_policy(".*", ["mcp:tools:admin"])
|
||||||
|
|
||||||
|
# Search tools - require read access
|
||||||
|
engine.add_tool_scope_policy("search.*", ["mcp:tools:read"])
|
||||||
|
|
||||||
|
# Fetch/get document tools - require read access
|
||||||
|
engine.add_tool_scope_policy("get_.*", ["mcp:tools:read"])
|
||||||
|
engine.add_tool_scope_policy("fetch.*", ["mcp:tools:read"])
|
||||||
|
|
||||||
|
# Specific Turkish legal database tools
|
||||||
|
engine.add_tool_scope_policy("search_yargitay.*", ["mcp:tools:read"])
|
||||||
|
engine.add_tool_scope_policy("search_danistay.*", ["mcp:tools:read"])
|
||||||
|
engine.add_tool_scope_policy("search_anayasa.*", ["mcp:tools:read"])
|
||||||
|
engine.add_tool_scope_policy("search_rekabet.*", ["mcp:tools:read"])
|
||||||
|
engine.add_tool_scope_policy("search_kik.*", ["mcp:tools:read"])
|
||||||
|
engine.add_tool_scope_policy("search_emsal.*", ["mcp:tools:read"])
|
||||||
|
engine.add_tool_scope_policy("search_uyusmazlik.*", ["mcp:tools:read"])
|
||||||
|
engine.add_tool_scope_policy("search_sayistay.*", ["mcp:tools:read"])
|
||||||
|
engine.add_tool_scope_policy("search_.*_bedesten", ["mcp:tools:read"])
|
||||||
|
engine.add_tool_scope_policy("search_yerel_hukuk.*", ["mcp:tools:read"])
|
||||||
|
engine.add_tool_scope_policy("search_istinaf_hukuk.*", ["mcp:tools:read"])
|
||||||
|
engine.add_tool_scope_policy("search_kyb.*", ["mcp:tools:read"])
|
||||||
|
|
||||||
|
# Document retrieval tools
|
||||||
|
engine.add_tool_scope_policy("get_.*_document.*", ["mcp:tools:read"])
|
||||||
|
engine.add_tool_scope_policy("get_.*_markdown", ["mcp:tools:read"])
|
||||||
|
|
||||||
|
# Write operations (if any future tools need them)
|
||||||
|
engine.add_tool_scope_policy("create_.*", ["mcp:tools:write"])
|
||||||
|
engine.add_tool_scope_policy("update_.*", ["mcp:tools:write"])
|
||||||
|
engine.add_tool_scope_policy("delete_.*", ["mcp:tools:write"])
|
||||||
|
|
||||||
|
logger.info("Created Turkish legal database policy engine")
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_policies() -> PolicyEngine:
|
||||||
|
"""Create a default policy set for MCP servers (backwards compatibility)"""
|
||||||
|
return create_turkish_legal_policies()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Persistent storage for OAuth sessions and tokens
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PersistentStorage:
|
||||||
|
"""File-based persistent storage for OAuth data"""
|
||||||
|
|
||||||
|
def __init__(self, storage_dir: str = None):
|
||||||
|
if storage_dir is None:
|
||||||
|
# Use system temp directory or environment variable
|
||||||
|
storage_dir = os.environ.get('TEMP', tempfile.gettempdir())
|
||||||
|
|
||||||
|
self.storage_dir = os.path.join(storage_dir, 'mcp_oauth_storage')
|
||||||
|
os.makedirs(self.storage_dir, exist_ok=True)
|
||||||
|
|
||||||
|
self.sessions_file = os.path.join(self.storage_dir, 'oauth_sessions.json')
|
||||||
|
self.tokens_file = os.path.join(self.storage_dir, 'oauth_tokens.json')
|
||||||
|
|
||||||
|
logger.info(f"Persistent OAuth storage initialized at: {self.storage_dir}")
|
||||||
|
|
||||||
|
def _load_json(self, filepath: str) -> Dict:
|
||||||
|
"""Load JSON data from file"""
|
||||||
|
try:
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading {filepath}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save_json(self, filepath: str, data: Dict):
|
||||||
|
"""Save JSON data to file"""
|
||||||
|
try:
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, indent=2, default=str)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving {filepath}: {e}")
|
||||||
|
|
||||||
|
def get_sessions(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Get all OAuth sessions"""
|
||||||
|
data = self._load_json(self.sessions_file)
|
||||||
|
# Clean expired sessions
|
||||||
|
now = datetime.utcnow().timestamp()
|
||||||
|
valid_sessions = {k: v for k, v in data.items()
|
||||||
|
if v.get('expires_at', 0) > now}
|
||||||
|
if len(valid_sessions) != len(data):
|
||||||
|
self._save_json(self.sessions_file, valid_sessions)
|
||||||
|
return valid_sessions
|
||||||
|
|
||||||
|
def set_session(self, session_id: str, data: Dict[str, Any]):
|
||||||
|
"""Set OAuth session data"""
|
||||||
|
sessions = self.get_sessions()
|
||||||
|
sessions[session_id] = data
|
||||||
|
self._save_json(self.sessions_file, sessions)
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get specific OAuth session data"""
|
||||||
|
sessions = self.get_sessions()
|
||||||
|
return sessions.get(session_id)
|
||||||
|
|
||||||
|
def delete_session(self, session_id: str):
|
||||||
|
"""Delete OAuth session"""
|
||||||
|
sessions = self.get_sessions()
|
||||||
|
if session_id in sessions:
|
||||||
|
del sessions[session_id]
|
||||||
|
self._save_json(self.sessions_file, sessions)
|
||||||
|
|
||||||
|
def get_tokens(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Get all OAuth tokens"""
|
||||||
|
data = self._load_json(self.tokens_file)
|
||||||
|
# Clean expired tokens
|
||||||
|
now = datetime.utcnow().timestamp()
|
||||||
|
valid_tokens = {k: v for k, v in data.items()
|
||||||
|
if v.get('expires_at', 0) > now}
|
||||||
|
if len(valid_tokens) != len(data):
|
||||||
|
self._save_json(self.tokens_file, valid_tokens)
|
||||||
|
return valid_tokens
|
||||||
|
|
||||||
|
def set_token(self, token_id: str, token_data: Dict[str, Any]):
|
||||||
|
"""Set OAuth token data"""
|
||||||
|
tokens = self.get_tokens()
|
||||||
|
tokens[token_id] = token_data
|
||||||
|
self._save_json(self.tokens_file, tokens)
|
||||||
|
|
||||||
|
def get_token(self, token_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get specific OAuth token data"""
|
||||||
|
tokens = self.get_tokens()
|
||||||
|
return tokens.get(token_id)
|
||||||
|
|
||||||
|
def delete_token(self, token_id: str):
|
||||||
|
"""Delete OAuth token"""
|
||||||
|
tokens = self.get_tokens()
|
||||||
|
if token_id in tokens:
|
||||||
|
del tokens[token_id]
|
||||||
|
self._save_json(self.tokens_file, tokens)
|
||||||
|
|
||||||
|
def cleanup_expired_sessions(self):
|
||||||
|
"""Clean up expired sessions and tokens"""
|
||||||
|
# This is handled automatically in get_sessions() and get_tokens()
|
||||||
|
sessions = self.get_sessions()
|
||||||
|
tokens = self.get_tokens()
|
||||||
|
logger.debug(f"Cleanup: {len(sessions)} active sessions, {len(tokens)} active tokens")
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""
|
||||||
|
Factory for creating FastMCP app with MCP Auth Toolkit integration
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
FASTMCP_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
FASTMCP_AVAILABLE = False
|
||||||
|
FastMCP = None
|
||||||
|
|
||||||
|
from mcp_auth import (
|
||||||
|
OAuthProvider,
|
||||||
|
PolicyEngine,
|
||||||
|
FastMCPAuthWrapper,
|
||||||
|
create_default_policies
|
||||||
|
)
|
||||||
|
from mcp_auth.clerk_config import create_mcp_server_config
|
||||||
|
|
||||||
|
|
||||||
|
def create_auth_enabled_app(app_name: str = "Yargı MCP Server") -> FastMCP:
|
||||||
|
"""Create FastMCP app with authentication enabled"""
|
||||||
|
|
||||||
|
if not FASTMCP_AVAILABLE:
|
||||||
|
raise ImportError("FastMCP is required for authenticated MCP server")
|
||||||
|
|
||||||
|
logger.info("Creating FastMCP app with MCP Auth Toolkit integration")
|
||||||
|
|
||||||
|
# Create base FastMCP app
|
||||||
|
app = FastMCP(app_name)
|
||||||
|
|
||||||
|
# Check if authentication is enabled
|
||||||
|
auth_enabled = os.getenv("ENABLE_AUTH", "true").lower() == "true"
|
||||||
|
|
||||||
|
if not auth_enabled:
|
||||||
|
logger.info("Authentication disabled, returning basic FastMCP app")
|
||||||
|
return app
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get configuration
|
||||||
|
logger.info("Getting MCP server configuration...")
|
||||||
|
config = create_mcp_server_config()
|
||||||
|
logger.info("Configuration loaded successfully")
|
||||||
|
|
||||||
|
# Create OAuth provider with Clerk config
|
||||||
|
logger.info("Creating OAuth provider...")
|
||||||
|
oauth_provider = OAuthProvider(
|
||||||
|
config=config["oauth_config"],
|
||||||
|
jwt_secret=config["jwt_secret"]
|
||||||
|
)
|
||||||
|
logger.info("OAuth provider created successfully")
|
||||||
|
|
||||||
|
# Create policy engine for Turkish legal database
|
||||||
|
policy_engine = create_default_policies()
|
||||||
|
|
||||||
|
# Store auth components for later wrapping (after tools are defined)
|
||||||
|
app._oauth_provider = oauth_provider
|
||||||
|
app._policy_engine = policy_engine
|
||||||
|
app._auth_config = config
|
||||||
|
|
||||||
|
# Add OAuth endpoints immediately
|
||||||
|
@app.tool(
|
||||||
|
description="Initiate OAuth 2.1 authorization flow with PKCE",
|
||||||
|
annotations={"readOnlyHint": True, "idempotentHint": False}
|
||||||
|
)
|
||||||
|
async def oauth_authorize(redirect_uri: str, scopes: str = None):
|
||||||
|
"""OAuth authorization endpoint"""
|
||||||
|
scope_list = scopes.split(" ") if scopes else ["mcp:tools:read", "mcp:tools:write"]
|
||||||
|
auth_url, pkce = oauth_provider.generate_authorization_url(
|
||||||
|
redirect_uri=redirect_uri, scopes=scope_list
|
||||||
|
)
|
||||||
|
logger.info(f"Generated authorization URL for redirect_uri: {redirect_uri}")
|
||||||
|
return {
|
||||||
|
"authorization_url": auth_url,
|
||||||
|
"code_verifier": pkce.verifier,
|
||||||
|
"code_challenge": pkce.challenge,
|
||||||
|
"instructions": "Use the authorization_url to complete OAuth flow, then exchange the returned code using oauth_token tool"
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
description="Exchange OAuth authorization code for access token",
|
||||||
|
annotations={"readOnlyHint": False, "idempotentHint": False}
|
||||||
|
)
|
||||||
|
async def oauth_token(code: str, state: str, redirect_uri: str):
|
||||||
|
"""OAuth token exchange endpoint"""
|
||||||
|
try:
|
||||||
|
result = await oauth_provider.exchange_code_for_token(
|
||||||
|
code=code, state=state, redirect_uri=redirect_uri
|
||||||
|
)
|
||||||
|
logger.info("Successfully exchanged authorization code for token")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token exchange failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
description="Validate and introspect OAuth access token",
|
||||||
|
annotations={"readOnlyHint": True, "idempotentHint": True}
|
||||||
|
)
|
||||||
|
async def oauth_introspect(token: str):
|
||||||
|
"""Token introspection endpoint"""
|
||||||
|
result = oauth_provider.introspect_token(token)
|
||||||
|
logger.debug(f"Token introspection: active={result.get('active', False)}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
description="Revoke OAuth access token",
|
||||||
|
annotations={"readOnlyHint": False, "idempotentHint": False}
|
||||||
|
)
|
||||||
|
async def oauth_revoke(token: str):
|
||||||
|
"""Token revocation endpoint"""
|
||||||
|
success = oauth_provider.revoke_token(token)
|
||||||
|
logger.info(f"Token revocation: success={success}")
|
||||||
|
return {"revoked": success}
|
||||||
|
|
||||||
|
logger.info("Successfully created authenticated FastMCP app")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create authenticated app: {e}")
|
||||||
|
logger.info("Falling back to non-authenticated FastMCP app")
|
||||||
|
# Return basic app if auth setup fails
|
||||||
|
return app
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastMCP:
|
||||||
|
"""Create FastMCP app (backwards compatible with mcp_factory.py)"""
|
||||||
|
return create_auth_enabled_app()
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_wrapper(app: FastMCP) -> Optional[FastMCPAuthWrapper]:
|
||||||
|
"""Get auth wrapper from app if available"""
|
||||||
|
return getattr(app, '_auth_wrapper', None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_oauth_provider(app: FastMCP) -> Optional[OAuthProvider]:
|
||||||
|
"""Get OAuth provider from app if available"""
|
||||||
|
return getattr(app, '_oauth_provider', None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_policy_engine(app: FastMCP) -> Optional[PolicyEngine]:
|
||||||
|
"""Get policy engine from app if available"""
|
||||||
|
return getattr(app, '_policy_engine', None)
|
||||||
|
|
||||||
|
|
||||||
|
def is_auth_enabled(app: FastMCP) -> bool:
|
||||||
|
"""Check if authentication is enabled for the app"""
|
||||||
|
return hasattr(app, '_oauth_provider') or hasattr(app, '_auth_wrapper')
|
||||||
|
|
||||||
|
|
||||||
|
def enable_tool_authentication(app: FastMCP):
|
||||||
|
"""Enable authentication on all existing tools (call after tools are defined)"""
|
||||||
|
if not is_auth_enabled(app):
|
||||||
|
logger.debug("Authentication not enabled, skipping tool authentication")
|
||||||
|
return
|
||||||
|
|
||||||
|
oauth_provider = get_oauth_provider(app)
|
||||||
|
policy_engine = get_policy_engine(app)
|
||||||
|
|
||||||
|
if not oauth_provider or not policy_engine:
|
||||||
|
logger.warning("OAuth provider or policy engine not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create auth wrapper and wrap tools
|
||||||
|
auth_wrapper = FastMCPAuthWrapper(
|
||||||
|
mcp_server=app,
|
||||||
|
oauth_provider=oauth_provider,
|
||||||
|
policy_engine=policy_engine
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store wrapper for reference
|
||||||
|
app._auth_wrapper = auth_wrapper
|
||||||
|
|
||||||
|
logger.info("Tool authentication enabled successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to enable tool authentication: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_auth_sessions(app: FastMCP):
|
||||||
|
"""Clean up expired auth sessions and tokens"""
|
||||||
|
oauth_provider = get_oauth_provider(app)
|
||||||
|
if oauth_provider:
|
||||||
|
oauth_provider.cleanup_expired_sessions()
|
||||||
|
logger.debug("Cleaned up expired OAuth sessions")
|
||||||
+6
-1
@@ -31,7 +31,7 @@ root_logger.addHandler(console_handler)
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
# --- Logging Configuration End ---
|
# --- Logging Configuration End ---
|
||||||
|
|
||||||
from mcp_factory import create_app
|
from mcp_auth_factory import create_app
|
||||||
|
|
||||||
# --- Module Imports ---
|
# --- Module Imports ---
|
||||||
from yargitay_mcp_module.client import YargitayOfficialApiClient
|
from yargitay_mcp_module.client import YargitayOfficialApiClient
|
||||||
@@ -2710,6 +2710,11 @@ async def fetch(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
from mcp_auth_factory import enable_tool_authentication
|
||||||
|
|
||||||
|
# Enable authentication on all tools now that they're defined
|
||||||
|
enable_tool_authentication(app)
|
||||||
|
|
||||||
logger.info(f"Starting {app.name} server via main() function...")
|
logger.info(f"Starting {app.name} server via main() function...")
|
||||||
logger.info(f"Logs will be written to: {LOG_FILE_PATH}")
|
logger.info(f"Logs will be written to: {LOG_FILE_PATH}")
|
||||||
try:
|
try:
|
||||||
|
|||||||
+12
-5
@@ -151,7 +151,8 @@ async def oauth_callback(
|
|||||||
error: Optional[str] = Query(None),
|
error: Optional[str] = Query(None),
|
||||||
error_description: Optional[str] = Query(None),
|
error_description: Optional[str] = Query(None),
|
||||||
grant_type: Optional[str] = Query(None),
|
grant_type: Optional[str] = Query(None),
|
||||||
redirect_uri: Optional[str] = Query(None)
|
redirect_uri: Optional[str] = Query(None),
|
||||||
|
redirect_url: Optional[str] = Query(None)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Handle OAuth callback from Clerk.
|
Handle OAuth callback from Clerk.
|
||||||
@@ -251,10 +252,16 @@ async def oauth_callback(
|
|||||||
# Always redirect back to the original redirect URL if provided
|
# Always redirect back to the original redirect URL if provided
|
||||||
original_redirect = redirect_uri or redirect_url
|
original_redirect = redirect_uri or redirect_url
|
||||||
if original_redirect:
|
if original_redirect:
|
||||||
# Redirect back with the authorization code and state
|
# For Claude.ai, redirect with access token
|
||||||
return RedirectResponse(
|
if "claude.ai" in original_redirect:
|
||||||
url=f"{original_redirect}?code={code}&state={state or ''}"
|
return RedirectResponse(
|
||||||
)
|
url=f"{original_redirect}?access_token={session_token}&token_type=Bearer"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# For other clients, redirect with authorization code
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"{original_redirect}?code={code}&state={state or ''}"
|
||||||
|
)
|
||||||
|
|
||||||
# If no redirect URL provided, return JSON response with session token
|
# If no redirect URL provided, return JSON response with session token
|
||||||
response = JSONResponse(content={
|
response = JSONResponse(content={
|
||||||
|
|||||||
+3
-2
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
"fastmcp>=2.9.2",
|
"fastmcp>=2.9.2",
|
||||||
"pypdf>=5.5.0",
|
"pypdf>=5.5.0",
|
||||||
"fastapi>=0.115.14",
|
"fastapi>=0.115.14",
|
||||||
|
"PyJWT>=2.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -38,7 +39,7 @@ saas = [
|
|||||||
yargi-mcp = "mcp_server_main:main"
|
yargi-mcp = "mcp_server_main:main"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
py-modules = ["mcp_server_main", "mcp_factory", "asgi_app", "fastapi_app", "starlette_app", "run_asgi", "oauth_middleware", "oauth_router", "stripe_webhook"]
|
py-modules = ["mcp_server_main", "mcp_factory", "mcp_auth_factory", "asgi_app", "fastapi_app", "starlette_app", "run_asgi", "oauth_middleware", "oauth_router", "stripe_webhook"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["*_mcp_module"]
|
include = ["*_mcp_module", "mcp_auth"]
|
||||||
|
|||||||
Reference in New Issue
Block a user