add clerk oauth

This commit is contained in:
saidsurucu
2025-07-01 17:31:48 +03:00
parent bbf99870eb
commit e0bba7625f
5 changed files with 571 additions and 80 deletions
+83 -50
View File
@@ -1,63 +1,96 @@
# Yargı MCP Server Environment Configuration # OAuth Configuration for Clerk + Google
# Copy this file to .env and customize as needed # Copy this file to .env and fill in your actual values
# Server Configuration # =============================================================================
# AUTHENTICATION SETTINGS
# =============================================================================
# Enable/disable authentication (set to "true" to enable OAuth)
ENABLE_AUTH=false
# =============================================================================
# CLERK CONFIGURATION
# =============================================================================
# Clerk API keys (get from https://dashboard.clerk.com/)
CLERK_SECRET_KEY=sk_test_your_secret_key_here
CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
# OAuth Redirect URLs
CLERK_OAUTH_REDIRECT_URL=http://localhost:8000/auth/callback
CLERK_FRONTEND_URL=http://localhost:3000
# Clerk domain issuer (usually auto-configured)
CLERK_ISSUER=https://your-clerk-domain.clerk.accounts.dev
# =============================================================================
# GOOGLE OAUTH SETTINGS
# =============================================================================
# Note: Google OAuth is configured through Clerk dashboard
# You need to:
# 1. Go to Clerk Dashboard > Social Connections
# 2. Enable Google provider
# 3. Add your Google OAuth client ID and secret
# 4. Configure redirect URIs in Google Console
# =============================================================================
# STRIPE CONFIGURATION (for payments/subscriptions)
# =============================================================================
STRIPE_SECRET=sk_test_your_stripe_secret_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# =============================================================================
# SERVER CONFIGURATION
# =============================================================================
# CORS origins (comma-separated list)
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8000,https://yourdomain.com
# Server settings
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8000 PORT=8000
LOG_LEVEL=INFO LOG_LEVEL=info
# Authentication Configuration # =============================================================================
ENABLE_AUTH=false # Set to true in production for JWT validation # MCP SERVER SETTINGS
# =============================================================================
# CORS Configuration # Additional MCP server configuration can go here
# Comma-separated list of allowed origins # For example, rate limiting, feature flags, etc.
# Use * to allow all origins (not recommended for production)
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080,https://yourdomain.com
# Authentication (optional) # Example: Rate limiting
# Uncomment and set to enable token-based authentication # MAX_REQUESTS_PER_MINUTE=60
# API_TOKEN=your-secret-token-here # BURST_CAPACITY=20
# Worker Configuration # =============================================================================
# Number of worker processes (for production) # USAGE INSTRUCTIONS
# WORKERS=4 # =============================================================================
# SSL Configuration (optional) # 1. Copy this file to .env:
# SSL_CERT_FILE=/path/to/cert.pem # cp .env.example .env
# SSL_KEY_FILE=/path/to/key.pem
# Database Timeouts (seconds) # 2. Get Clerk credentials:
# Adjust based on your network conditions # - Sign up at https://clerk.com/
YARGITAY_TIMEOUT=60 # - Create a new application
DANISTAY_TIMEOUT=60 # - Go to API Keys tab
BEDESTEN_TIMEOUT=60 # - Copy Secret Key and Publishable Key
ANAYASA_TIMEOUT=90
KIK_TIMEOUT=45
REKABET_TIMEOUT=45
UYUSMAZLIK_TIMEOUT=30
EMSAL_TIMEOUT=60
# Development Settings # 3. Configure Google OAuth in Clerk:
# Enable debug mode (not for production) # - In Clerk Dashboard, go to Social Connections
# DEBUG=false # - Enable Google provider
# - Get Google OAuth credentials from Google Console
# - Add redirect URI: http://localhost:8000/auth/callback
# Monitoring (optional) # 4. Update OAuth URLs:
# Sentry DSN for error tracking # - Set CLERK_OAUTH_REDIRECT_URL to your callback URL
# SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id # - Set CLERK_FRONTEND_URL to your frontend application URL
# ------ Clerk ------ # 5. Enable authentication:
CLERK_PUBLISHABLE_KEY=pk_test_xxx # - Set ENABLE_AUTH=true
CLERK_SECRET_KEY=sk_test_xxx
CLERK_ISSUER=https://your-app.clerk.accounts.dev # issuer & JWKS root (optional if using CLERK_PUBLIC_KEY)
# CLERK_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----\nMIIBIjANBg...\n-----END PUBLIC KEY-----
# ------ Stripe ------ # 6. Test the OAuth flow:
STRIPE_SECRET=sk_live_xxx # - Start server: uvicorn asgi_app:app --reload
STRIPE_WEBHOOK_SECRET=whsec_xxx # - Visit: http://localhost:8000/auth/login
# - Complete OAuth flow with Google
# Application Configuration # - Check: http://localhost:8000/auth/user
APP_URL=http://localhost:8000
# OpenTelemetry Configuration (optional)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
# OTEL_SERVICE_NAME=yargi-mcp-server
+16 -5
View File
@@ -21,6 +21,9 @@ 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
from oauth_router import router as oauth_router
# Configure CORS middleware # Configure CORS middleware
cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",") cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",")
custom_middleware = [ custom_middleware = [
@@ -42,7 +45,7 @@ mcp_app = mcp_server.http_app(
# Create FastAPI wrapper application with MCP app's lifespan # Create FastAPI wrapper application with MCP app's lifespan
app = FastAPI( app = FastAPI(
title="Yargı MCP Server", title="Yargı MCP Server",
description="MCP server for Turkish legal databases with JWT authentication", description="MCP server for Turkish legal databases with OAuth authentication",
version="0.1.0", version="0.1.0",
middleware=custom_middleware, middleware=custom_middleware,
lifespan=mcp_app.lifespan # Critical: Get lifespan from mcp_app, not mcp_server lifespan=mcp_app.lifespan # Critical: Get lifespan from mcp_app, not mcp_server
@@ -51,6 +54,9 @@ 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
app.include_router(oauth_router)
# Mount MCP app as sub-application # Mount MCP app as sub-application
app.mount("/mcp", mcp_app) app.mount("/mcp", mcp_app)
@@ -72,12 +78,16 @@ async def root():
"""Root endpoint with service information""" """Root endpoint with service information"""
return JSONResponse({ return JSONResponse({
"service": "Yargı MCP Server", "service": "Yargı MCP Server",
"description": "MCP server for Turkish legal databases with JWT authentication", "description": "MCP server for Turkish legal databases with OAuth authentication",
"endpoints": { "endpoints": {
"mcp": "/mcp/", "mcp": "/mcp/",
"health": "/health", "health": "/health",
"status": "/status", "status": "/status",
"stripe_webhook": "/api/stripe/webhook" "stripe_webhook": "/api/stripe/webhook",
"oauth_login": "/auth/login",
"oauth_callback": "/auth/callback",
"oauth_google": "/auth/google/login",
"user_info": "/auth/user"
}, },
"supported_databases": [ "supported_databases": [
"Yargıtay (Court of Cassation)", "Yargıtay (Court of Cassation)",
@@ -92,9 +102,10 @@ async def root():
], ],
"authentication": { "authentication": {
"enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true", "enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true",
"type": "JWT Bearer Token", "type": "OAuth 2.0 via Clerk",
"issuer": os.getenv("CLERK_ISSUER", "https://clerk.accounts.dev"), "issuer": os.getenv("CLERK_ISSUER", "https://clerk.accounts.dev"),
"required_scopes": ["yargi.read"] "providers": ["google"],
"flow": "authorization_code"
} }
}) })
+24 -25
View File
@@ -2,35 +2,34 @@ import os
from functools import lru_cache from functools import lru_cache
from fastmcp import FastMCP from fastmcp import FastMCP
from fastmcp.server.auth import BearerAuthProvider from fastmcp.server.auth import BearerAuthProvider
from oauth_middleware import ClerkOAuthMiddleware
@lru_cache @lru_cache
def create_app() -> FastMCP: def create_app() -> FastMCP:
"""Return a FastMCP instance; Clerk JWT validation when ENABLE_AUTH=true.""" """Return a FastMCP instance; OAuth authentication when ENABLE_AUTH=true."""
if os.getenv("ENABLE_AUTH", "false").lower() != "true": # Base app configuration
return FastMCP( app_config = {
name="Yargı MCP DEV", "instructions": "MCP server for TR legal databases (Yargitay, Danistay, Emsal, Uyusmazlik, Anayasa-Norm, Anayasa-Bireysel, KIK, Sayistay, Rekabet).",
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"]
dependencies=["httpx", "beautifulsoup4", "markitdown", "pydantic", "aiohttp", "playwright"] }
)
# Public key'i environment variable'dan al, yoksa None
public_key_pem = os.environ.get("CLERK_PUBLIC_KEY")
if public_key_pem: if os.getenv("ENABLE_AUTH", "false").lower() != "true":
# Eğer public key varsa, onu kullan (production) # Development mode - no authentication
auth = BearerAuthProvider( app = FastMCP(
public_key=public_key_pem, name="Yargı MCP DEV",
# issuer, audience ve required_scopes kontrollerini yapmıyoruz **app_config
) )
else: else:
# Public key yoksa JWKS endpoint kullan (development/fallback) # Production mode - OAuth authentication via middleware
clerk_issuer = os.environ.get("CLERK_ISSUER", "https://clerk.accounts.dev") app = FastMCP(
auth = BearerAuthProvider( name="Yargı MCP PROD",
jwks_uri=f"{clerk_issuer}/.well-known/jwks.json", **app_config
) )
return FastMCP(
name="Yargı MCP PROD", # Add OAuth middleware instead of BearerAuthProvider
auth=auth, app.add_middleware(ClerkOAuthMiddleware())
instructions="MCP server for TR legal databases (Yargitay, Danistay, Emsal, Uyusmazlik, Anayasa-Norm, Anayasa-Bireysel, KIK, Sayistay, Rekabet) with JWT authentication.",
dependencies=["httpx", "beautifulsoup4", "markitdown", "pydantic", "aiohttp", "playwright"] # Update instructions to reflect OAuth
) app.instructions += " with OAuth authentication via Clerk."
return app
+178
View File
@@ -0,0 +1,178 @@
"""
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
from clerk_backend_api import Clerk
from clerk_backend_api.errors import SDKError
from clerk_backend_api.security import authenticate_request
from clerk_backend_api.security.types import AuthenticateRequestOptions
from mcp import McpError
from mcp.types import ErrorData
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.clerk_secret = os.getenv("CLERK_SECRET_KEY")
if not self.clerk_secret:
raise ValueError("CLERK_SECRET_KEY environment variable is required")
self.clerk = Clerk(bearer_auth=self.clerk_secret)
self.enable_auth = os.getenv("ENABLE_AUTH", "false").lower() == "true"
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
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.
Returns user info if token is valid, None otherwise.
"""
try:
# 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 _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)
+270
View File
@@ -0,0 +1,270 @@
"""
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 clerk_backend_api import Clerk
from clerk_backend_api.errors import SDKError
from clerk_backend_api.security import authenticate_request
from clerk_backend_api.security.types import AuthenticateRequestOptions
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth")
# Initialize Clerk client
clerk_secret = os.getenv("CLERK_SECRET_KEY")
clerk_publishable = os.getenv("CLERK_PUBLISHABLE_KEY")
clerk_frontend_url = os.getenv("CLERK_FRONTEND_URL", "http://localhost:3000")
redirect_url = os.getenv("CLERK_OAUTH_REDIRECT_URL", "http://localhost:8000/auth/callback")
if not clerk_secret:
raise ValueError("CLERK_SECRET_KEY environment variable is required")
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
clerk_oauth_params = {
"redirect_url": redirect_uri or redirect_url,
}
# For Clerk, we typically use their hosted sign-in page
# or the Clerk.js frontend SDK
clerk_sign_in_url = f"https://{clerk_publishable.split('_')[1]}.clerk.accounts.dev/sign-in"
# Add redirect URL as a query parameter
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("/callback")
async def oauth_callback(
request: Request,
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
error_description: Optional[str] = None
):
"""
Handle OAuth callback from Clerk.
This endpoint receives the authorization code from Clerk
and exchanges it for an access token.
"""
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:
# In a typical OAuth flow, we would exchange the code for tokens here
# However, Clerk handles this differently - the frontend SDK manages tokens
# For server-side validation, we need to:
# 1. Use Clerk's session tokens (not raw OAuth tokens)
# 2. Or implement a custom session management system
# For now, we'll create a session token that can be validated by our middleware
# In production, you'd want to:
# - Store this in a database/cache
# - Set proper expiration
# - Link to user's Clerk ID
session_token = secrets.token_urlsafe(64)
# Return the session token to the client
# In a real app, you might:
# 1. Set this as an HTTP-only cookie
# 2. Redirect to the frontend with the token
# 3. Store in a secure session store
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 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.
"""
# Build Clerk sign-in URL with Google as the provider
clerk_domain = clerk_publishable.split('_')[1]
google_oauth_url = f"https://{clerk_domain}.clerk.accounts.dev/sign-in#/?strategy=oauth_google"
return RedirectResponse(url=google_oauth_url)
@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)})