attempt to fix auth
This commit is contained in:
+6
-6
@@ -23,12 +23,12 @@ from mcp_server_main import app as mcp_server
|
|||||||
# Import Stripe webhook router
|
# Import Stripe webhook router
|
||||||
from stripe_webhook import router as stripe_router
|
from stripe_webhook import router as stripe_router
|
||||||
|
|
||||||
# Import OAuth router
|
# Import MCP Auth HTTP adapter
|
||||||
from oauth_router import router as oauth_router
|
from mcp_auth_http_adapter import router as mcp_auth_router
|
||||||
|
|
||||||
# OAuth configuration from environment variables
|
# OAuth configuration from environment variables
|
||||||
CLERK_ISSUER = os.getenv("CLERK_ISSUER", "https://artistic-swan-81.clerk.accounts.dev")
|
CLERK_ISSUER = os.getenv("CLERK_ISSUER", "https://accounts.yargimcp.com")
|
||||||
BASE_URL = os.getenv("BASE_URL", "https://yargi-mcp.fly.dev")
|
BASE_URL = os.getenv("BASE_URL", "https://yargimcp.com")
|
||||||
|
|
||||||
# Configure CORS middleware
|
# Configure CORS middleware
|
||||||
cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",")
|
cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",")
|
||||||
@@ -60,8 +60,8 @@ app = FastAPI(
|
|||||||
# Add Stripe webhook router to FastAPI
|
# Add Stripe webhook router to FastAPI
|
||||||
app.include_router(stripe_router, prefix="/api")
|
app.include_router(stripe_router, prefix="/api")
|
||||||
|
|
||||||
# Add OAuth router to FastAPI
|
# Add MCP Auth HTTP adapter to FastAPI (replaces old OAuth router)
|
||||||
app.include_router(oauth_router)
|
app.include_router(mcp_auth_router)
|
||||||
|
|
||||||
# Custom 401 exception handler for MCP spec compliance
|
# Custom 401 exception handler for MCP spec compliance
|
||||||
@app.exception_handler(401)
|
@app.exception_handler(401)
|
||||||
|
|||||||
@@ -191,6 +191,13 @@ class OAuthProvider:
|
|||||||
"scope": " ".join(session["scopes"]),
|
"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(
|
def _create_mcp_token(
|
||||||
self, scopes: list[str], upstream_token: str, session_id: str
|
self, scopes: list[str], upstream_token: str, session_id: str
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
)
|
||||||
@@ -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
|
|
||||||
@@ -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 <token>"
|
|
||||||
))
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
-499
@@ -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)})
|
|
||||||
+1
-1
@@ -39,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", "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]
|
[tool.setuptools.packages.find]
|
||||||
include = ["*_mcp_module", "mcp_auth"]
|
include = ["*_mcp_module", "mcp_auth"]
|
||||||
|
|||||||
Reference in New Issue
Block a user