From 7e6819affabf9a487224f717cd2a477302b59c81 Mon Sep 17 00:00:00 2001 From: saidsurucu Date: Wed, 2 Jul 2025 02:03:28 +0300 Subject: [PATCH] fix oauth session --- mcp_auth/__init__.py | 28 ++++ mcp_auth/clerk_config.py | 76 ++++++++++ mcp_auth/middleware.py | 315 +++++++++++++++++++++++++++++++++++++++ mcp_auth/oauth.py | 268 +++++++++++++++++++++++++++++++++ mcp_auth/policy.py | 201 +++++++++++++++++++++++++ mcp_auth/storage.py | 112 ++++++++++++++ mcp_auth_factory.py | 193 ++++++++++++++++++++++++ mcp_server_main.py | 7 +- oauth_router.py | 17 ++- pyproject.toml | 5 +- 10 files changed, 1214 insertions(+), 8 deletions(-) create mode 100644 mcp_auth/__init__.py create mode 100644 mcp_auth/clerk_config.py create mode 100644 mcp_auth/middleware.py create mode 100644 mcp_auth/oauth.py create mode 100644 mcp_auth/policy.py create mode 100644 mcp_auth/storage.py create mode 100644 mcp_auth_factory.py diff --git a/mcp_auth/__init__.py b/mcp_auth/__init__.py new file mode 100644 index 0000000..0ed22c9 --- /dev/null +++ b/mcp_auth/__init__.py @@ -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", +] \ No newline at end of file diff --git a/mcp_auth/clerk_config.py b/mcp_auth/clerk_config.py new file mode 100644 index 0000000..ac3805e --- /dev/null +++ b/mcp_auth/clerk_config.py @@ -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 \ No newline at end of file diff --git a/mcp_auth/middleware.py b/mcp_auth/middleware.py new file mode 100644 index 0000000..0dc6301 --- /dev/null +++ b/mcp_auth/middleware.py @@ -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") \ No newline at end of file diff --git a/mcp_auth/oauth.py b/mcp_auth/oauth.py new file mode 100644 index 0000000..2005e00 --- /dev/null +++ b/mcp_auth/oauth.py @@ -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") \ No newline at end of file diff --git a/mcp_auth/policy.py b/mcp_auth/policy.py new file mode 100644 index 0000000..e947af3 --- /dev/null +++ b/mcp_auth/policy.py @@ -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() \ No newline at end of file diff --git a/mcp_auth/storage.py b/mcp_auth/storage.py new file mode 100644 index 0000000..ea1ad8f --- /dev/null +++ b/mcp_auth/storage.py @@ -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") \ No newline at end of file diff --git a/mcp_auth_factory.py b/mcp_auth_factory.py new file mode 100644 index 0000000..a5760f6 --- /dev/null +++ b/mcp_auth_factory.py @@ -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") \ No newline at end of file diff --git a/mcp_server_main.py b/mcp_server_main.py index 47be9cf..6554fb2 100644 --- a/mcp_server_main.py +++ b/mcp_server_main.py @@ -31,7 +31,7 @@ root_logger.addHandler(console_handler) logger = logging.getLogger(__name__) # --- Logging Configuration End --- -from mcp_factory import create_app +from mcp_auth_factory import create_app # --- Module Imports --- from yargitay_mcp_module.client import YargitayOfficialApiClient @@ -2710,6 +2710,11 @@ async def fetch( raise 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"Logs will be written to: {LOG_FILE_PATH}") try: diff --git a/oauth_router.py b/oauth_router.py index 3a1cd15..b460892 100644 --- a/oauth_router.py +++ b/oauth_router.py @@ -151,7 +151,8 @@ async def oauth_callback( error: Optional[str] = Query(None), error_description: 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. @@ -251,10 +252,16 @@ async def oauth_callback( # Always redirect back to the original redirect URL if provided original_redirect = redirect_uri or redirect_url if original_redirect: - # Redirect back with the authorization code and state - return RedirectResponse( - url=f"{original_redirect}?code={code}&state={state or ''}" - ) + # For Claude.ai, redirect with access token + if "claude.ai" in original_redirect: + 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 response = JSONResponse(content={ diff --git a/pyproject.toml b/pyproject.toml index 53ce0ca..cc3a9ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "fastmcp>=2.9.2", "pypdf>=5.5.0", "fastapi>=0.115.14", + "PyJWT>=2.8.0", ] [project.optional-dependencies] @@ -38,7 +39,7 @@ saas = [ yargi-mcp = "mcp_server_main:main" [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] -include = ["*_mcp_module"] +include = ["*_mcp_module", "mcp_auth"]