add clerk oauth
This commit is contained in:
+83
-50
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+22
-23
@@ -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."""
|
||||||
|
# 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"]
|
||||||
|
}
|
||||||
|
|
||||||
if os.getenv("ENABLE_AUTH", "false").lower() != "true":
|
if os.getenv("ENABLE_AUTH", "false").lower() != "true":
|
||||||
return FastMCP(
|
# Development mode - no authentication
|
||||||
|
app = FastMCP(
|
||||||
name="Yargı MCP – DEV",
|
name="Yargı MCP – DEV",
|
||||||
instructions="MCP server for TR legal databases (Yargitay, Danistay, Emsal, Uyusmazlik, Anayasa-Norm, Anayasa-Bireysel, KIK, Sayistay, Rekabet).",
|
**app_config
|
||||||
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:
|
|
||||||
# Eğer public key varsa, onu kullan (production)
|
|
||||||
auth = BearerAuthProvider(
|
|
||||||
public_key=public_key_pem,
|
|
||||||
# issuer, audience ve required_scopes kontrollerini yapmıyoruz
|
|
||||||
)
|
)
|
||||||
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(
|
|
||||||
jwks_uri=f"{clerk_issuer}/.well-known/jwks.json",
|
|
||||||
)
|
|
||||||
return FastMCP(
|
|
||||||
name="Yargı MCP – PROD",
|
name="Yargı MCP – PROD",
|
||||||
auth=auth,
|
**app_config
|
||||||
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"]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add OAuth middleware instead of BearerAuthProvider
|
||||||
|
app.add_middleware(ClerkOAuthMiddleware())
|
||||||
|
|
||||||
|
# Update instructions to reflect OAuth
|
||||||
|
app.instructions += " with OAuth authentication via Clerk."
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -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
@@ -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)})
|
||||||
Reference in New Issue
Block a user