diff --git a/asgi_app.py b/asgi_app.py index 8e6e259..6a841f2 100644 --- a/asgi_app.py +++ b/asgi_app.py @@ -23,12 +23,12 @@ from mcp_server_main import app as mcp_server # Import Stripe webhook router from stripe_webhook import router as stripe_router -# Import OAuth router -from oauth_router import router as oauth_router +# Import MCP Auth HTTP adapter +from mcp_auth_http_adapter import router as mcp_auth_router # OAuth configuration from environment variables -CLERK_ISSUER = os.getenv("CLERK_ISSUER", "https://artistic-swan-81.clerk.accounts.dev") -BASE_URL = os.getenv("BASE_URL", "https://yargi-mcp.fly.dev") +CLERK_ISSUER = os.getenv("CLERK_ISSUER", "https://accounts.yargimcp.com") +BASE_URL = os.getenv("BASE_URL", "https://yargimcp.com") # Configure CORS middleware cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",") @@ -60,8 +60,8 @@ app = FastAPI( # Add Stripe webhook router to FastAPI app.include_router(stripe_router, prefix="/api") -# Add OAuth router to FastAPI -app.include_router(oauth_router) +# Add MCP Auth HTTP adapter to FastAPI (replaces old OAuth router) +app.include_router(mcp_auth_router) # Custom 401 exception handler for MCP spec compliance @app.exception_handler(401) diff --git a/mcp_auth/oauth.py b/mcp_auth/oauth.py index 2005e00..b6d1e99 100644 --- a/mcp_auth/oauth.py +++ b/mcp_auth/oauth.py @@ -191,6 +191,13 @@ class OAuthProvider: "scope": " ".join(session["scopes"]), } + def validate_pkce(self, code_verifier: str, code_challenge: str) -> bool: + """Validate PKCE code challenge (RFC 7636)""" + # S256 method + verifier_hash = hashlib.sha256(code_verifier.encode()).digest() + expected_challenge = base64.urlsafe_b64encode(verifier_hash).decode().rstrip('=') + return expected_challenge == code_challenge + def _create_mcp_token( self, scopes: list[str], upstream_token: str, session_id: str ) -> str: diff --git a/mcp_auth_http_adapter.py b/mcp_auth_http_adapter.py new file mode 100644 index 0000000..4de9952 --- /dev/null +++ b/mcp_auth_http_adapter.py @@ -0,0 +1,275 @@ +""" +HTTP adapter for MCP Auth Toolkit OAuth endpoints +Exposes MCP OAuth tools as HTTP endpoints for Claude.ai integration +""" + +import os +import logging +from typing import Optional +from urllib.parse import urlencode +from datetime import datetime + +from fastapi import APIRouter, Request, Query, HTTPException +from fastapi.responses import RedirectResponse, JSONResponse + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# OAuth configuration +BASE_URL = os.getenv("BASE_URL", "https://yargimcp.com") + + +@router.get("/.well-known/oauth-authorization-server") +async def get_oauth_metadata(): + """OAuth 2.0 Authorization Server Metadata (RFC 8414)""" + return JSONResponse({ + "issuer": BASE_URL, + "authorization_endpoint": f"{BASE_URL}/authorize", + "token_endpoint": f"{BASE_URL}/token", + "registration_endpoint": f"{BASE_URL}/register", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"], + "scopes_supported": ["mcp:tools:read", "mcp:tools:write", "openid", "profile", "email"] + }) + + +@router.get("/.well-known/oauth-protected-resource") +async def get_protected_resource_metadata(): + """OAuth Protected Resource Metadata (RFC 9728)""" + return JSONResponse({ + "resource": BASE_URL, + "authorization_servers": [BASE_URL], + "bearer_methods_supported": ["header"], + "scopes_supported": ["mcp:tools:read", "mcp:tools:write"], + "resource_documentation": f"{BASE_URL}/docs" + }) + + +@router.get("/authorize") +async def authorize_endpoint( + response_type: str = Query(...), + client_id: str = Query(...), + redirect_uri: str = Query(...), + code_challenge: str = Query(...), + code_challenge_method: str = Query("S256"), + state: Optional[str] = Query(None), + scope: Optional[str] = Query(None) +): + """OAuth 2.1 Authorization Endpoint - Redirects to MCP Auth tool""" + + logger.info(f"OAuth authorize request - client_id: {client_id}, redirect_uri: {redirect_uri}") + + # Import here to avoid circular imports + try: + from mcp_server_main import app as mcp_app + from mcp_auth_factory import get_oauth_provider + + # Get OAuth provider from MCP app + oauth_provider = get_oauth_provider(mcp_app) + if not oauth_provider: + logger.error("OAuth provider not available in MCP app") + raise HTTPException(status_code=500, detail="OAuth provider not configured") + + # Generate authorization URL using MCP Auth Toolkit + auth_url, pkce = oauth_provider.generate_authorization_url( + redirect_uri=redirect_uri, + state=state, + scopes=scope.split(" ") if scope else None + ) + + logger.info(f"Generated auth URL: {auth_url[:100]}...") + + # Redirect to Clerk OAuth + return RedirectResponse(url=auth_url) + + except Exception as e: + logger.exception(f"Authorization failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/auth/callback") +async def oauth_callback( + code: Optional[str] = Query(None), + state: Optional[str] = Query(None), + error: Optional[str] = Query(None), + error_description: Optional[str] = Query(None) +): + """Handle OAuth callback from Clerk""" + + logger.info(f"OAuth callback - code: {code[:20] if code else 'None'}..., state: {state[:20] if state else 'None'}...") + + if error: + logger.error(f"OAuth error: {error} - {error_description}") + return JSONResponse( + status_code=400, + content={"error": error, "error_description": error_description} + ) + + if not code or not state: + logger.error("Missing code or state in callback") + return JSONResponse( + status_code=400, + content={"error": "invalid_request", "error_description": "Missing code or state parameter"} + ) + + try: + # Import here to avoid circular imports + from mcp_server_main import app as mcp_app + from mcp_auth_factory import get_oauth_provider + + # Get OAuth provider + oauth_provider = get_oauth_provider(mcp_app) + if not oauth_provider: + raise HTTPException(status_code=500, detail="OAuth provider not configured") + + # Parse state to get original client state and session ID + try: + original_state, session_id = state.split(":", 1) + except ValueError: + logger.error(f"Invalid state format: {state}") + raise HTTPException(status_code=400, detail="Invalid state format") + + # Get session data from storage + session = oauth_provider.storage.get_session(session_id) + if not session: + logger.error(f"Session {session_id} not found") + raise HTTPException(status_code=400, detail="Invalid session") + + # Exchange code for token with Clerk + token_result = await oauth_provider.exchange_code_for_token( + code=code, + state=state, + redirect_uri=session["redirect_uri"] + ) + + # Build redirect URL back to Claude with authorization code + # The "code" here is our session ID that Claude will exchange for a token + redirect_params = { + "code": session_id, + "state": original_state + } + + redirect_url = f"{session['redirect_uri']}?{urlencode(redirect_params)}" + logger.info(f"Redirecting back to Claude: {redirect_url}") + + return RedirectResponse(url=redirect_url) + + except Exception as e: + logger.exception(f"Callback processing failed: {e}") + # Try to redirect back with error + if session and "redirect_uri" in session: + error_params = { + "error": "server_error", + "error_description": str(e), + "state": original_state if 'original_state' in locals() else state + } + error_url = f"{session['redirect_uri']}?{urlencode(error_params)}" + return RedirectResponse(url=error_url) + else: + return JSONResponse( + status_code=500, + content={"error": "server_error", "error_description": str(e)} + ) + + +@router.post("/register") +async def register_client(request: Request): + """Dynamic Client Registration (RFC 7591)""" + + data = await request.json() + logger.info(f"Client registration request: {data}") + + # Simple dynamic registration - accept any client + client_id = f"mcp-client-{os.urandom(8).hex()}" + + return JSONResponse({ + "client_id": client_id, + "client_secret": None, # Public client + "redirect_uris": data.get("redirect_uris", []), + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "client_name": data.get("client_name", "MCP Client"), + "token_endpoint_auth_method": "none", + "client_id_issued_at": int(datetime.now().timestamp()) + }) + + +@router.post("/token") +async def token_endpoint(request: Request): + """OAuth 2.1 Token Endpoint""" + + # Parse form data + form_data = await request.form() + grant_type = form_data.get("grant_type") + code = form_data.get("code") + redirect_uri = form_data.get("redirect_uri") + client_id = form_data.get("client_id") + code_verifier = form_data.get("code_verifier") + + logger.info(f"Token exchange - grant_type: {grant_type}, code: {code[:20] if code else 'None'}...") + + if grant_type != "authorization_code": + return JSONResponse( + status_code=400, + content={"error": "unsupported_grant_type"} + ) + + try: + # Import here to avoid circular imports + from mcp_server_main import app as mcp_app + from mcp_auth_factory import get_oauth_provider + + # Get OAuth provider + oauth_provider = get_oauth_provider(mcp_app) + if not oauth_provider: + raise HTTPException(status_code=500, detail="OAuth provider not configured") + + # The "code" is actually our session ID + session_id = code + session = oauth_provider.storage.get_session(session_id) + + if not session: + logger.error(f"Session {session_id} not found for token exchange") + return JSONResponse( + status_code=400, + content={"error": "invalid_grant", "error_description": "Invalid authorization code"} + ) + + # Validate PKCE + if "pkce_verifier" in session: + # Session has the verifier stored, validate it matches + if code_verifier != session["pkce_verifier"]: + logger.error("PKCE verifier mismatch") + return JSONResponse( + status_code=400, + content={"error": "invalid_grant", "error_description": "Invalid code verifier"} + ) + else: + logger.warning("No PKCE verifier in session, skipping validation") + + # Create JWT token + access_token = oauth_provider._create_mcp_token( + session["scopes"], + session.get("clerk_token", ""), + session_id + ) + + # Clean up session + oauth_provider.storage.delete_session(session_id) + + return JSONResponse({ + "access_token": access_token, + "token_type": "Bearer", + "expires_in": 3600, + "scope": " ".join(session["scopes"]) + }) + + except Exception as e: + logger.exception(f"Token exchange failed: {e}") + return JSONResponse( + status_code=500, + content={"error": "server_error", "error_description": str(e)} + ) \ No newline at end of file diff --git a/mcp_factory.py b/mcp_factory.py deleted file mode 100644 index 182508a..0000000 --- a/mcp_factory.py +++ /dev/null @@ -1,48 +0,0 @@ -import os -from functools import lru_cache -from fastmcp import FastMCP -from fastmcp.server.auth import BearerAuthProvider - -# Conditional import for OAuth middleware -try: - from oauth_middleware import ClerkOAuthMiddleware - OAUTH_AVAILABLE = True -except ImportError: - # OAuth middleware not available - will disable OAuth features - OAUTH_AVAILABLE = False - ClerkOAuthMiddleware = None - -@lru_cache -def create_app() -> FastMCP: - """Return a FastMCP instance; OAuth authentication when ENABLE_AUTH=true.""" - # Base app configuration - app_config = { - "instructions": "MCP server for TR legal databases (Yargitay, Danistay, Emsal, Uyusmazlik, Anayasa-Norm, Anayasa-Bireysel, KIK, Sayistay, Rekabet).", - "dependencies": ["httpx", "beautifulsoup4", "markitdown", "pydantic", "aiohttp", "playwright"] - } - - enable_auth = os.getenv("ENABLE_AUTH", "false").lower() == "true" - - if not enable_auth or not OAUTH_AVAILABLE: - # Development mode - no authentication - # Either auth is disabled OR OAuth dependencies not available - app = FastMCP( - name="Yargı MCP – DEV", - **app_config - ) - - if enable_auth and not OAUTH_AVAILABLE: - print("Warning: OAuth authentication requested but dependencies not available.") - print("Install with: uv pip install .[saas]") - else: - # Production mode - OAuth authentication via middleware - app_config["instructions"] += " with OAuth authentication via Clerk." - app = FastMCP( - name="Yargı MCP – PROD", - **app_config - ) - - # Add OAuth middleware instead of BearerAuthProvider - app.add_middleware(ClerkOAuthMiddleware()) - - return app \ No newline at end of file diff --git a/oauth_middleware.py b/oauth_middleware.py deleted file mode 100644 index 10a8ea1..0000000 --- a/oauth_middleware.py +++ /dev/null @@ -1,245 +0,0 @@ -""" -OAuth Middleware for FastMCP Server -Handles Clerk OAuth token validation and user context -""" - -import os -import logging -from typing import Optional, Dict, Any -from fastmcp.server.middleware import Middleware, MiddlewareContext -try: - from clerk_backend_api import Clerk, SDKError, authenticate_request, AuthenticateRequestOptions - CLERK_AVAILABLE = True -except ImportError: - # Clerk SDK not available - OAuth features will be disabled - CLERK_AVAILABLE = False - Clerk = None - SDKError = Exception - authenticate_request = None - AuthenticateRequestOptions = None -from mcp import McpError -from mcp.types import ErrorData -from starlette.responses import Response - -logger = logging.getLogger(__name__) - - -class ClerkOAuthMiddleware(Middleware): - """ - Middleware that validates OAuth tokens via Clerk API and adds user context. - - This middleware intercepts MCP requests over HTTP transport and validates - OAuth access tokens provided in the Authorization header. - """ - - def __init__(self): - """Initialize the middleware with Clerk client.""" - self.enable_auth = os.getenv("ENABLE_AUTH", "false").lower() == "true" - self.clerk_secret = os.getenv("CLERK_SECRET_KEY") - - # Check if Clerk SDK is available - if self.enable_auth and not CLERK_AVAILABLE: - raise ValueError("Clerk SDK not available. Install with: uv pip install .[saas]") - - # Only require Clerk credentials if auth is enabled - if self.enable_auth and not self.clerk_secret: - raise ValueError("CLERK_SECRET_KEY environment variable is required when ENABLE_AUTH=true") - - # Initialize Clerk client only if auth is enabled and available - self.clerk = None - if self.enable_auth and self.clerk_secret and CLERK_AVAILABLE: - self.clerk = Clerk(bearer_auth=self.clerk_secret) - - async def on_request(self, context: MiddlewareContext, call_next): - """ - Validate OAuth token on every request. - - For HTTP transport: - 1. Extract OAuth token from Authorization header - 2. Validate token with Clerk API - 3. Add user info to context - 4. Check user permissions/plan - """ - # Skip auth if disabled - if not self.enable_auth: - return await call_next(context) - - # Check if this is an HTTP transport request - if not hasattr(context, 'fastmcp_context') or not context.fastmcp_context: - # Non-HTTP transport (e.g., stdio), skip auth - return await call_next(context) - - # Try to get the request object from context - request = getattr(context.fastmcp_context, 'request', None) - if not request: - # No HTTP request object, likely stdio transport - return await call_next(context) - - # Check for Authorization header (Clerk SDK will handle token extraction) - auth_header = request.headers.get('Authorization', '') - if not auth_header.startswith('Bearer '): - raise McpError(ErrorData( - code=-32001, - message="Missing or invalid Authorization header. Expected: Bearer " - )) - - # Validate token and get user info using Clerk SDK - if not self.clerk: - raise McpError(ErrorData( - code=-32001, - message="Authentication service not available" - )) - - user_info = self._validate_oauth_token(request) - if not user_info: - raise McpError(ErrorData( - code=-32001, - message="Invalid or expired OAuth token" - )) - - # Add user info to context for downstream use - context.user_info = user_info - - # Check user permissions/plan - if not self._check_user_permissions(user_info): - raise McpError(ErrorData( - code=-32002, - message="Insufficient permissions. Upgrade your plan for access." - )) - - logger.info(f"Authenticated user: {user_info.get('id')} ({user_info.get('email')})") - - # Continue with the request - return await call_next(context) - - def _validate_oauth_token(self, request) -> Optional[Dict[str, Any]]: - """ - Validate OAuth token using Clerk SDK's authenticate_request method. - For development tokens, decode directly. - - Returns user info if token is valid, None otherwise. - """ - try: - # Check for development token first - auth_header = request.headers.get('Authorization', '') - if auth_header.startswith('Bearer dev_token_'): - return self._validate_dev_token(auth_header) - - # Use Clerk SDK for production tokens - # Get the host for authorized parties - host = request.url.host if hasattr(request.url, 'host') else 'localhost' - - # Use Clerk SDK's authenticate_request method - request_state = self.clerk.authenticate_request( - request, - AuthenticateRequestOptions( - # Accept both session tokens and OAuth tokens - accepts_token=['session', 'oauth_token'], - authorized_parties=[host, 'localhost', '127.0.0.1'] - ) - ) - - if request_state.is_signed_in and request_state.payload: - payload = request_state.payload - - # Extract user information from JWT payload - return { - "id": payload.get("sub"), # Subject (user ID) - "email": payload.get("email"), - "first_name": payload.get("given_name"), - "last_name": payload.get("family_name"), - "metadata": payload.get("metadata", {}), - "plan": payload.get("metadata", {}).get("plan", "free"), - "session_id": payload.get("sid"), # Session ID - "org_id": payload.get("org_id"), # Organization ID (if any) - "org_role": payload.get("org_role"), # Organization role (if any) - "iat": payload.get("iat"), # Issued at - "exp": payload.get("exp"), # Expires at - } - else: - logger.warning(f"Token validation failed: {request_state.reason if hasattr(request_state, 'reason') else 'Unknown reason'}") - return None - - except SDKError as e: - logger.error(f"Clerk SDK error validating token: {e}") - return None - except Exception as e: - logger.error(f"Unexpected error validating OAuth token: {e}") - return None - - def _validate_dev_token(self, auth_header: str) -> Optional[Dict[str, Any]]: - """ - Validate development token for testing purposes. - """ - try: - import json - import base64 - import time - - # Extract token data - token = auth_header.replace('Bearer dev_token_', '') - payload_json = base64.b64decode(token).decode() - payload = json.loads(payload_json) - - # Check expiration - if payload.get('exp', 0) < time.time(): - logger.warning("Development token expired") - return None - - # Return user info - return { - "id": payload.get("sub"), - "email": payload.get("email"), - "first_name": payload.get("given_name"), - "last_name": payload.get("family_name"), - "metadata": payload.get("metadata", {}), - "plan": payload.get("metadata", {}).get("plan", "free"), - "session_id": payload.get("sid"), - "org_id": payload.get("org_id"), - "org_role": payload.get("org_role"), - "iat": payload.get("iat"), - "exp": payload.get("exp") - } - - except Exception as e: - logger.error(f"Error validating development token: {e}") - return None - - def _check_user_permissions(self, user_info: Dict[str, Any]) -> bool: - """ - Check if user has necessary permissions based on their plan. - - This is where you can implement role-based access control. - """ - # Get user's plan from metadata - user_plan = user_info.get("plan", "free") - - # For now, allow all authenticated users - # You can implement more sophisticated permission checks here - # For example: - # - Free users: limited to X requests per day - # - Pro users: full access - # - Enterprise: priority access + higher limits - - return True # Allow all authenticated users for now - - async def on_call_tool(self, context: MiddlewareContext, call_next): - """ - Additional validation for tool calls. - - Can be used to implement tool-specific permissions. - """ - # Check if user has access to this specific tool - if hasattr(context, 'user_info'): - tool_name = context.message.name if hasattr(context.message, 'name') else None - user_plan = context.user_info.get('plan', 'free') - - # Example: Restrict certain tools to paid users - premium_tools = ["advanced_analysis", "bulk_export"] - if tool_name in premium_tools and user_plan == 'free': - raise McpError(ErrorData( - code=-32002, - message=f"Tool '{tool_name}' requires a Pro plan or higher" - )) - - return await call_next(context) \ No newline at end of file diff --git a/oauth_router.py b/oauth_router.py deleted file mode 100644 index b460892..0000000 --- a/oauth_router.py +++ /dev/null @@ -1,499 +0,0 @@ -""" -OAuth Authentication Router for FastAPI -Handles OAuth flow with Clerk and Google -""" - -import os -import secrets -import logging -from typing import Optional -from datetime import datetime, timedelta -from urllib.parse import urlencode - -from fastapi import APIRouter, Request, Response, HTTPException, Query -from fastapi.responses import RedirectResponse, JSONResponse -from starlette.responses import Response as StarletteResponse -try: - from clerk_backend_api import Clerk, SDKError, authenticate_request, AuthenticateRequestOptions - CLERK_AVAILABLE = True -except ImportError: - # Clerk SDK not available - OAuth features will be disabled - CLERK_AVAILABLE = False - Clerk = None - SDKError = Exception - authenticate_request = None - AuthenticateRequestOptions = None - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/auth") - -# Initialize Clerk client conditionally -clerk_secret = os.getenv("CLERK_SECRET_KEY") -clerk_publishable = os.getenv("CLERK_PUBLISHABLE_KEY") -clerk_domain = os.getenv("CLERK_DOMAIN") # e.g., "artistic-swan-81" -clerk_issuer = os.getenv("CLERK_ISSUER", f"https://{clerk_domain}.accounts.dev" if clerk_domain else None) -base_url = os.getenv("BASE_URL", "https://yargi-mcp.fly.dev") -clerk_frontend_url = os.getenv("CLERK_FRONTEND_URL", "http://localhost:3000") -redirect_url = os.getenv("CLERK_OAUTH_REDIRECT_URL", f"{base_url}/auth/callback") -enable_auth = os.getenv("ENABLE_AUTH", "false").lower() == "true" - -# Check if Clerk SDK is available when auth is enabled -if enable_auth and not CLERK_AVAILABLE: - raise ValueError("Clerk SDK not available. Install with: uv pip install .[saas]") - -# Only require Clerk credentials if auth is enabled -if enable_auth and not clerk_secret: - raise ValueError("CLERK_SECRET_KEY environment variable is required when ENABLE_AUTH=true") - -# Initialize Clerk client only if auth is enabled and available -clerk = None -if enable_auth and clerk_secret and CLERK_AVAILABLE: - clerk = Clerk(bearer_auth=clerk_secret) - - -@router.get("/login") -async def oauth_login(request: Request, redirect_uri: Optional[str] = None): - """ - Initiate OAuth login flow with Clerk. - - This endpoint redirects to Clerk's OAuth authorization URL. - After user authorizes, they'll be redirected back to /auth/callback - """ - # Store the original redirect URI in session/state - state = secrets.token_urlsafe(32) - - # Build Clerk OAuth URL - # Note: Clerk handles the OAuth flow internally, we just need to redirect to Clerk's sign-in - final_redirect = redirect_uri or redirect_url - - # For ChatGPT, ensure the redirect URL is properly encoded - if "chatgpt.com" in (final_redirect or ""): - final_redirect = "https://chatgpt.com/connector_platform_oauth_redirect" - - clerk_oauth_params = { - "redirect_url": final_redirect, - } - - # For Clerk test environment, redirect to our own OAuth endpoint - # which will handle the Clerk OAuth flow properly - - # Always use Clerk hosted OAuth (force production-style flow) - # Get domain from environment variable or extract from publishable key - if clerk_domain: - domain = clerk_domain - elif clerk_publishable: - # Extract domain from key format: pk_test_xxxxx or pk_live_xxxxx - key_parts = clerk_publishable.split('_') - if len(key_parts) >= 3: - domain = key_parts[2] # Domain is usually the third part - else: - # Fallback to environment variable if key parsing fails - domain = os.getenv("CLERK_DOMAIN", "localhost") - else: - # Use environment variable as fallback - domain = os.getenv("CLERK_DOMAIN", "localhost") - - # Production Clerk hosted sign-in URL - # Check if domain already includes full URL or just subdomain - if domain.startswith('http'): - # Full URL provided - clerk_sign_in_url = f"{domain}/sign-in" - elif '.' in domain and not domain.endswith('.accounts.dev'): - # Custom domain like clerk.yargimcp.com - clerk_sign_in_url = f"https://{domain}/sign-in" - else: - # Standard Clerk subdomain - clerk_sign_in_url = f"https://{domain}.accounts.dev/sign-in" - - oauth_url = f"{clerk_sign_in_url}?{urlencode(clerk_oauth_params)}" - - logger.info(f"Redirecting to Clerk OAuth: {oauth_url}") - - return RedirectResponse(url=oauth_url) - - -@router.get("/clerk-oauth") -async def clerk_oauth_handler(request: Request, redirect_url: Optional[str] = None): - """ - Handle Clerk OAuth flow for test environment. - - This endpoint creates a mock OAuth flow that simulates Clerk's behavior - but works around the 404 issues in test environment. - """ - # For development/testing, create a simulated OAuth flow - # In production, this would integrate with Clerk's actual OAuth endpoints - - # Generate a mock authorization code - auth_code = secrets.token_urlsafe(32) - state = secrets.token_urlsafe(16) - - # For ChatGPT, redirect back with the authorization code - if redirect_url and "chatgpt.com" in redirect_url: - callback_url = f"{redirect_url}?code={auth_code}&state={state}" - return RedirectResponse(url=callback_url) - - # For other clients, show a simple OAuth consent page - return JSONResponse({ - "message": "OAuth Authorization Required", - "authorization_url": f"/auth/callback?code={auth_code}&state={state}", - "redirect_url": redirect_url or "http://localhost:3000", - "note": "This is a development OAuth flow. In production, use Clerk's hosted OAuth." - }) - - -@router.get("/callback") -@router.post("/callback") -async def oauth_callback( - request: Request, - code: Optional[str] = Query(None), - state: Optional[str] = Query(None), - error: Optional[str] = Query(None), - error_description: Optional[str] = Query(None), - grant_type: Optional[str] = Query(None), - redirect_uri: Optional[str] = Query(None), - redirect_url: Optional[str] = Query(None) -): - """ - Handle OAuth callback from Clerk. - - This endpoint receives the authorization code from Clerk - and exchanges it for an access token. - """ - # Handle POST requests with form data - if request.method == "POST": - try: - form_data = await request.form() - code = code or form_data.get("code") - grant_type = grant_type or form_data.get("grant_type") - redirect_uri = redirect_uri or form_data.get("redirect_uri") - state = state or form_data.get("state") - except Exception: - pass # Continue with query parameters - - if error: - logger.error(f"OAuth error: {error} - {error_description}") - return JSONResponse( - status_code=400, - content={"error": error, "description": error_description} - ) - - if not code: - raise HTTPException(status_code=400, detail="Missing authorization code") - - try: - # Handle Clerk hosted OAuth callback - # In production, this exchanges the authorization code with Clerk for session tokens - - if enable_auth and clerk and CLERK_AVAILABLE: - # Production: Exchange code with Clerk for real session token - try: - # Use Clerk SDK to exchange authorization code for session - # This would typically involve calling Clerk's token exchange endpoint - logger.info(f"Exchanging authorization code with Clerk: {code}") - - # For now, create a development token until Clerk SDK token exchange is implemented - import time - import json - import base64 - - jwt_payload = { - "sub": f"clerk_user_{secrets.token_urlsafe(8)}", - "iss": clerk_issuer, - "aud": base_url, - "iat": int(time.time()), - "exp": int(time.time()) + 3600, # 1 hour - "email": "user@example.com", - "given_name": "Clerk", - "family_name": "User", - "sid": f"clerk_session_{secrets.token_urlsafe(8)}", - "metadata": {"plan": "free", "oauth_provider": "clerk_hosted"} - } - - token_data = base64.b64encode(json.dumps(jwt_payload).encode()).decode() - session_token = f"clerk_token_{token_data}" - - except Exception as e: - logger.error(f"Clerk token exchange failed: {e}") - raise HTTPException(status_code=500, detail="OAuth token exchange failed") - else: - # Development mode: Create mock token - import time - import json - import base64 - - jwt_payload = { - "sub": "dev_user_123", - "iss": clerk_issuer, - "aud": base_url, - "iat": int(time.time()), - "exp": int(time.time()) + 3600, # 1 hour - "email": "dev@example.com", - "given_name": "Dev", - "family_name": "User", - "sid": "dev_session_123", - "metadata": {"plan": "free"} - } - - token_data = base64.b64encode(json.dumps(jwt_payload).encode()).decode() - session_token = f"dev_token_{token_data}" - - # Check if this is a token exchange request (POST with grant_type) - if request.method == "POST" and grant_type == "authorization_code": - # OAuth 2.1 token response - return JSONResponse(content={ - "access_token": session_token, - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": f"refresh_{secrets.token_urlsafe(32)}", - "scope": "read search" - }) - - # Always redirect back to the original redirect URL if provided - original_redirect = redirect_uri or redirect_url - if original_redirect: - # 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={ - "status": "success", - "message": "Authentication successful", - "session_token": session_token, - "redirect_url": clerk_frontend_url - }) - - # Optionally set as cookie - response.set_cookie( - key="mcp_session", - value=session_token, - httponly=True, - secure=True, # Use HTTPS in production - samesite="lax", - max_age=86400 # 24 hours - ) - - return response - - except Exception as e: - logger.error(f"OAuth callback error: {e}") - raise HTTPException(status_code=500, detail="Authentication failed") - - -@router.post("/logout") -async def logout(request: Request, response: Response): - """ - Logout user by clearing session. - """ - # Clear session cookie - response.delete_cookie("mcp_session") - - # If using Clerk session tokens, revoke them here - # You might also want to call Clerk's signOut endpoint - - return JSONResponse(content={ - "status": "success", - "message": "Logged out successfully" - }) - - -@router.get("/user") -async def get_current_user(request: Request): - """ - Get current authenticated user information using Clerk SDK. - - This endpoint validates the token and returns user info using authenticate_request. - """ - # Check if auth is disabled - if not enable_auth: - return JSONResponse(content={ - "auth_disabled": True, - "message": "Authentication is disabled (ENABLE_AUTH=false)", - "id": "dev_user", - "email": "dev@example.com", - "authenticated": False, - "development_mode": True - }) - - # Check if Clerk is not initialized - if not clerk: - raise HTTPException(status_code=500, detail="Authentication service not available") - - # Check for Authorization header - auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): - # Also check for session cookie as fallback - session_token = request.cookies.get("mcp_session") - if not session_token: - raise HTTPException(status_code=401, detail="Not authenticated") - - try: - # Use Clerk SDK to validate the request - host = request.url.host if hasattr(request.url, 'host') else 'localhost' - - request_state = clerk.authenticate_request( - request, - AuthenticateRequestOptions( - accepts_token=['session', 'oauth_token'], - authorized_parties=[host, 'localhost', '127.0.0.1'] - ) - ) - - if not request_state.is_signed_in: - raise HTTPException(status_code=401, detail="Invalid or expired token") - - # Extract user info from JWT payload - payload = request_state.payload - user_info = { - "id": payload.get("sub"), - "email": payload.get("email"), - "first_name": payload.get("given_name"), - "last_name": payload.get("family_name"), - "metadata": payload.get("metadata", {}), - "plan": payload.get("metadata", {}).get("plan", "free"), - "session_id": payload.get("sid"), - "org_id": payload.get("org_id"), - "org_role": payload.get("org_role"), - "authenticated": True, - "iat": payload.get("iat"), - "exp": payload.get("exp") - } - - return JSONResponse(content=user_info) - - except SDKError as e: - logger.error(f"Clerk SDK error: {e}") - raise HTTPException(status_code=401, detail="Authentication failed") - except Exception as e: - logger.error(f"Error fetching user info: {e}") - raise HTTPException(status_code=401, detail="Invalid session") - - -@router.get("/google/login") -async def google_oauth_login(request: Request): - """ - Initiate Google OAuth login through Clerk. - - Clerk handles the OAuth provider connections, - so we redirect to Clerk's sign-in with Google specified. - """ - # Extract domain using same logic as main OAuth flow - if clerk_domain: - domain = clerk_domain - elif clerk_publishable: - key_parts = clerk_publishable.split('_') - if len(key_parts) >= 3: - domain = key_parts[2] - else: - # Fallback to environment variable if key parsing fails - domain = os.getenv("CLERK_DOMAIN", "localhost") - else: - # Use environment variable as fallback - domain = os.getenv("CLERK_DOMAIN", "localhost") - - # Build Clerk sign-in URL with Google as the provider - # Check if domain already includes full URL or just subdomain - if domain.startswith('http'): - # Full URL provided - google_oauth_url = f"{domain}/sign-in#/?strategy=oauth_google" - elif '.' in domain and not domain.endswith('.accounts.dev'): - # Custom domain like clerk.yargimcp.com - google_oauth_url = f"https://{domain}/sign-in#/?strategy=oauth_google" - else: - # Standard Clerk subdomain - google_oauth_url = f"https://{domain}.accounts.dev/sign-in#/?strategy=oauth_google" - - return RedirectResponse(url=google_oauth_url) - - -@router.post("/register") -async def dynamic_client_registration(request: Request): - """ - OAuth 2.0 Dynamic Client Registration (RFC 7591) - MCP Spec SHOULD support. - - For development/testing, returns a static client configuration. - In production, this would integrate with Clerk's client management. - """ - try: - # In a real implementation, you would: - # 1. Validate the request - # 2. Register the client with Clerk - # 3. Return proper client credentials - - # For now, return a development client configuration - return JSONResponse(content={ - "client_id": "yargi-mcp-dynamic-client", - "client_secret": "dev-client-secret-123", - "client_id_issued_at": 1625097600, - "client_secret_expires_at": 0, # Never expires for development - "redirect_uris": [ - "https://chatgpt.com/connector_platform_oauth_redirect", - "https://chatgpt.com/auth/callback", - "http://localhost:3000/auth/callback" - ], - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "scope": "read search openid profile email", - "token_endpoint_auth_method": "client_secret_basic" - }) - - except Exception as e: - logger.error(f"Dynamic client registration error: {e}") - raise HTTPException(status_code=400, detail="Invalid client registration request") - - -@router.get("/session/validate") -async def validate_session(request: Request): - """ - Validate if the current session is active using Clerk SDK. - - Used by clients to check auth status. - """ - auth_header = request.headers.get("Authorization", "") - session_token = request.cookies.get("mcp_session") - - if not auth_header.startswith("Bearer ") and not session_token: - return JSONResponse(content={"valid": False}) - - try: - # Use Clerk SDK to validate the request - host = request.url.host if hasattr(request.url, 'host') else 'localhost' - - request_state = clerk.authenticate_request( - request, - AuthenticateRequestOptions( - accepts_token=['session', 'oauth_token'], - authorized_parties=[host, 'localhost', '127.0.0.1'] - ) - ) - - if request_state.is_signed_in and request_state.payload: - # Get expiration time from JWT payload - exp_timestamp = request_state.payload.get("exp") - expires_at = datetime.utcfromtimestamp(exp_timestamp).isoformat() if exp_timestamp else None - - return JSONResponse(content={ - "valid": True, - "user_id": request_state.payload.get("sub"), - "session_id": request_state.payload.get("sid"), - "expires_at": expires_at, - "org_id": request_state.payload.get("org_id"), - "org_role": request_state.payload.get("org_role") - }) - else: - return JSONResponse(content={ - "valid": False, - "reason": getattr(request_state, 'reason', 'Unknown') - }) - - except Exception as e: - logger.error(f"Session validation error: {e}") - return JSONResponse(content={"valid": False, "error": str(e)}) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index cc3a9ed..eb81d7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ saas = [ yargi-mcp = "mcp_server_main:main" [tool.setuptools] -py-modules = ["mcp_server_main", "mcp_factory", "mcp_auth_factory", "asgi_app", "fastapi_app", "starlette_app", "run_asgi", "oauth_middleware", "oauth_router", "stripe_webhook"] +py-modules = ["mcp_server_main", "mcp_auth_factory", "mcp_auth_http_adapter", "asgi_app", "fastapi_app", "starlette_app", "run_asgi", "stripe_webhook"] [tool.setuptools.packages.find] include = ["*_mcp_module", "mcp_auth"]