improve clerk sdk use

This commit is contained in:
saidsurucu
2025-07-02 02:43:53 +03:00
parent e70554dcc9
commit 8d91d2c764
4 changed files with 253 additions and 100 deletions
+12 -15
View File
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
def create_clerk_oauth_config() -> OAuthConfig: def create_clerk_oauth_config() -> OAuthConfig:
"""Create OAuth configuration for Clerk integration""" """Create OAuth configuration for Clerk integration using SDK"""
# Get Clerk configuration from environment # Get Clerk configuration from environment
clerk_domain = os.getenv("CLERK_DOMAIN", "accounts.yargimcp.com") clerk_domain = os.getenv("CLERK_DOMAIN", "accounts.yargimcp.com")
@@ -20,27 +20,24 @@ def create_clerk_oauth_config() -> OAuthConfig:
if not clerk_publishable_key or not clerk_secret_key: if not clerk_publishable_key or not clerk_secret_key:
raise ValueError("CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY are required") raise ValueError("CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY are required")
# Determine if custom domain or standard Clerk domain # For Clerk with custom domains, we use our adapter endpoints
if '.' in clerk_domain and not clerk_domain.endswith('.accounts.dev'): # This allows us to handle the custom domain flow properly
# Custom domain like accounts.yargimcp.com base_url = os.getenv("BASE_URL", "https://yargimcp.com")
base_url = f"https://{clerk_domain}"
issuer = f"https://{clerk_domain}"
else:
# Standard Clerk subdomain
base_url = f"https://{clerk_domain}.accounts.dev"
issuer = f"https://{clerk_domain}.accounts.dev"
config = OAuthConfig( config = OAuthConfig(
client_id=clerk_publishable_key, client_id=clerk_publishable_key,
client_secret=clerk_secret_key, client_secret=clerk_secret_key,
authorization_endpoint=f"{base_url}/oauth/authorize", # Use our adapter endpoints instead of Clerk's direct endpoints
token_endpoint=f"{base_url}/oauth/token", authorization_endpoint=f"{base_url}/authorize",
jwks_uri=f"{base_url}/.well-known/jwks.json", token_endpoint=f"{base_url}/token",
issuer=issuer, # Keep Clerk's JWKS for token validation
jwks_uri=f"https://{clerk_domain}/.well-known/jwks.json",
issuer=base_url, # We're the issuer for MCP tokens
scopes=["mcp:tools:read", "mcp:tools:write", "openid", "profile", "email"] scopes=["mcp:tools:read", "mcp:tools:write", "openid", "profile", "email"]
) )
logger.info(f"Created Clerk OAuth config for domain: {clerk_domain}") logger.info(f"Created Clerk OAuth config with adapter endpoints")
logger.info(f"Clerk domain: {clerk_domain}")
logger.debug(f"Authorization endpoint: {config.authorization_endpoint}") logger.debug(f"Authorization endpoint: {config.authorization_endpoint}")
logger.debug(f"Token endpoint: {config.token_endpoint}") logger.debug(f"Token endpoint: {config.token_endpoint}")
+31 -2
View File
@@ -18,6 +18,14 @@ from jwt.exceptions import PyJWTError, InvalidTokenError
from .storage import PersistentStorage from .storage import PersistentStorage
# Try to import Clerk SDK
try:
from clerk_backend_api import Clerk
CLERK_AVAILABLE = True
except ImportError:
CLERK_AVAILABLE = False
Clerk = None
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -62,6 +70,16 @@ class OAuthProvider:
self.jwt_secret = jwt_secret self.jwt_secret = jwt_secret
# Use persistent storage instead of memory # Use persistent storage instead of memory
self.storage = PersistentStorage() self.storage = PersistentStorage()
# Initialize Clerk SDK if available
self.clerk = None
if CLERK_AVAILABLE and config.client_secret:
try:
self.clerk = Clerk(bearer_auth=config.client_secret)
logger.info("Clerk SDK initialized successfully")
except Exception as e:
logger.warning(f"Failed to initialize Clerk SDK: {e}")
logger.info("OAuth provider initialized with persistent storage") logger.info("OAuth provider initialized with persistent storage")
def generate_authorization_url( def generate_authorization_url(
@@ -92,7 +110,17 @@ class OAuthProvider:
} }
self.storage.set_session(session_id, session_data) self.storage.set_session(session_id, session_data)
# Build Clerk OAuth URL with PKCE # Build Clerk OAuth URL
# Check if this is a custom domain (sign-in endpoint)
if self.config.authorization_endpoint.endswith('/sign-in'):
# For custom domains, Clerk expects redirect_url parameter
params = {
"redirect_url": redirect_uri,
"state": f"{state}:{session_id}",
}
auth_url = f"{self.config.authorization_endpoint}?{urlencode(params)}"
else:
# Standard OAuth flow with PKCE
params = { params = {
"response_type": "code", "response_type": "code",
"client_id": self.config.client_id, "client_id": self.config.client_id,
@@ -102,9 +130,10 @@ class OAuthProvider:
"code_challenge": pkce.challenge, "code_challenge": pkce.challenge,
"code_challenge_method": "S256", "code_challenge_method": "S256",
} }
auth_url = f"{self.config.authorization_endpoint}?{urlencode(params)}" auth_url = f"{self.config.authorization_endpoint}?{urlencode(params)}"
logger.info(f"Generated OAuth URL with session {session_id[:8]}...") logger.info(f"Generated OAuth URL with session {session_id[:8]}...")
logger.debug(f"Auth URL: {auth_url}")
return auth_url, pkce return auth_url, pkce
async def exchange_code_for_token( async def exchange_code_for_token(
+194 -68
View File
@@ -6,12 +6,26 @@ Exposes MCP OAuth tools as HTTP endpoints for Claude.ai integration
import os import os
import logging import logging
from typing import Optional from typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode, quote
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Request, Query, HTTPException from fastapi import APIRouter, Request, Query, HTTPException
from fastapi.responses import RedirectResponse, JSONResponse from fastapi.responses import RedirectResponse, JSONResponse
# Try to import Clerk SDK
try:
from clerk_backend_api import Clerk
from clerk_backend_api.types import UnprocessableEntityError
CLERK_AVAILABLE = True
except ImportError:
CLERK_AVAILABLE = False
Clerk = None
UnprocessableEntityError = None
import secrets
import time
from datetime import datetime, timedelta
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -58,32 +72,63 @@ async def authorize_endpoint(
state: Optional[str] = Query(None), state: Optional[str] = Query(None),
scope: Optional[str] = Query(None) scope: Optional[str] = Query(None)
): ):
"""OAuth 2.1 Authorization Endpoint - Redirects to MCP Auth tool""" """OAuth 2.1 Authorization Endpoint - Uses Clerk SDK for custom domains"""
logger.info(f"OAuth authorize request - client_id: {client_id}, redirect_uri: {redirect_uri}") logger.info(f"OAuth authorize request - client_id: {client_id}, redirect_uri: {redirect_uri}")
# Import here to avoid circular imports if not CLERK_AVAILABLE:
logger.error("Clerk SDK not available")
raise HTTPException(status_code=500, detail="Clerk SDK not available")
# Store OAuth session for later validation
try: try:
from mcp_server_main import app as mcp_app from mcp_server_main import app as mcp_app
from mcp_auth_factory import get_oauth_provider from mcp_auth_factory import get_oauth_provider
# Get OAuth provider from MCP app
oauth_provider = get_oauth_provider(mcp_app) oauth_provider = get_oauth_provider(mcp_app)
if not oauth_provider: if not oauth_provider:
logger.error("OAuth provider not available in MCP app")
raise HTTPException(status_code=500, detail="OAuth provider not configured") raise HTTPException(status_code=500, detail="OAuth provider not configured")
# Generate authorization URL using MCP Auth Toolkit # Generate session and store PKCE
auth_url, pkce = oauth_provider.generate_authorization_url( session_id = secrets.token_urlsafe(32)
redirect_uri=redirect_uri, if state is None:
state=state, state = secrets.token_urlsafe(16)
scopes=scope.split(" ") if scope else None
)
logger.info(f"Generated auth URL: {auth_url[:100]}...") # Create PKCE challenge
from mcp_auth.oauth import PKCEChallenge
pkce = PKCEChallenge()
# Redirect to Clerk OAuth # Store session data
return RedirectResponse(url=auth_url) session_data = {
"pkce_verifier": pkce.verifier,
"pkce_challenge": code_challenge, # Store the client's challenge
"state": state,
"redirect_uri": redirect_uri,
"client_id": client_id,
"scopes": scope.split(" ") if scope else ["mcp:tools:read", "mcp:tools:write"],
"created_at": time.time(),
"expires_at": (datetime.utcnow() + timedelta(minutes=10)).timestamp(),
}
oauth_provider.storage.set_session(session_id, session_data)
# For Clerk with custom domains, we need to use their hosted sign-in page
# We'll pass our callback URL and session info in the state
callback_url = f"{BASE_URL}/auth/callback"
# Encode session info in state for retrieval after Clerk auth
combined_state = f"{state}:{session_id}"
# Use Clerk's sign-in URL with proper parameters
clerk_domain = os.getenv("CLERK_DOMAIN", "accounts.yargimcp.com")
sign_in_params = {
"redirect_url": f"{callback_url}?state={quote(combined_state)}",
}
sign_in_url = f"https://{clerk_domain}/sign-in?{urlencode(sign_in_params)}"
logger.info(f"Redirecting to Clerk sign-in: {sign_in_url}")
return RedirectResponse(url=sign_in_url)
except Exception as e: except Exception as e:
logger.exception(f"Authorization failed: {e}") logger.exception(f"Authorization failed: {e}")
@@ -92,83 +137,147 @@ async def authorize_endpoint(
@router.get("/auth/callback") @router.get("/auth/callback")
async def oauth_callback( async def oauth_callback(
code: Optional[str] = Query(None), request: Request,
state: Optional[str] = Query(None), state: Optional[str] = Query(None),
error: Optional[str] = Query(None), __session: Optional[str] = Query(None),
error_description: Optional[str] = Query(None) __client_uat: Optional[str] = Query(None)
): ):
"""Handle OAuth callback from Clerk""" """Handle OAuth callback from Clerk - verify authentication and issue code"""
logger.info(f"OAuth callback - code: {code[:20] if code else 'None'}..., state: {state[:20] if state else 'None'}...") logger.info(f"OAuth callback received - state: {state}")
if error: # Check if we have Clerk SDK
logger.error(f"OAuth error: {error} - {error_description}") if not CLERK_AVAILABLE:
logger.error("Clerk SDK not available")
return JSONResponse( return JSONResponse(
status_code=400, status_code=500,
content={"error": error, "error_description": error_description} content={"error": "server_error", "error_description": "Clerk SDK not available"}
) )
if not code or not state: # Get Clerk session from cookies or query params
logger.error("Missing code or state in callback") session_token = request.cookies.get("__session") or __session
client_uat = request.cookies.get("__client_uat") or __client_uat
# Initialize Clerk SDK
clerk_secret = os.getenv("CLERK_SECRET_KEY")
if not clerk_secret:
logger.error("CLERK_SECRET_KEY not configured")
return JSONResponse( return JSONResponse(
status_code=400, status_code=500,
content={"error": "invalid_request", "error_description": "Missing code or state parameter"} content={"error": "server_error", "error_description": "Clerk not configured"}
) )
clerk = Clerk(bearer_auth=clerk_secret)
try: try:
# Import here to avoid circular imports # Verify the session with Clerk SDK
session = None
user = None
if session_token:
try:
# Verify session token with Clerk
logger.info("Verifying session token with Clerk SDK")
session = clerk.sessions.verify_token(
token=session_token,
session_token=session_token
)
logger.info(f"Session verified: {session.id if hasattr(session, 'id') else 'unknown'}")
# Get user info
if hasattr(session, 'user_id'):
user = clerk.users.get(user_id=session.user_id)
logger.info(f"User authenticated: {user.id}")
except Exception as e:
logger.warning(f"Session verification failed: {e}")
# If no valid session, check client UAT (User Authentication Token)
if not session and client_uat:
try:
logger.info("Verifying client UAT with Clerk SDK")
# Try to get current user with client token
clients = clerk.clients.verify_token(token=client_uat)
if clients and hasattr(clients, 'sessions') and clients.sessions:
session = clients.sessions[0]
logger.info(f"Client session found: {session.id}")
except Exception as e:
logger.warning(f"Client UAT verification failed: {e}")
if not session:
logger.error("No valid Clerk session found")
return JSONResponse(
status_code=401,
content={"error": "unauthorized", "error_description": "No valid Clerk session found"}
)
# Extract session ID from state
if not state:
logger.error("No state parameter provided")
return JSONResponse(
status_code=400,
content={"error": "invalid_request", "error_description": "Missing state parameter"}
)
# Parse state to get original state and session ID
try:
if ":" in state:
original_state, session_id = state.rsplit(":", 1)
else:
original_state = state
session_id = None
except ValueError:
logger.error(f"Invalid state format: {state}")
return JSONResponse(
status_code=400,
content={"error": "invalid_request", "error_description": "Invalid state format"}
)
# Get OAuth provider
from mcp_server_main import app as mcp_app from mcp_server_main import app as mcp_app
from mcp_auth_factory import get_oauth_provider from mcp_auth_factory import get_oauth_provider
# Get OAuth provider
oauth_provider = get_oauth_provider(mcp_app) oauth_provider = get_oauth_provider(mcp_app)
if not oauth_provider: if not oauth_provider:
raise HTTPException(status_code=500, detail="OAuth provider not configured") raise HTTPException(status_code=500, detail="OAuth provider not configured")
# Parse state to get original client state and session ID # Get stored session
try: oauth_session = None
original_state, session_id = state.split(":", 1) if session_id:
except ValueError: oauth_session = oauth_provider.storage.get_session(session_id)
logger.error(f"Invalid state format: {state}")
raise HTTPException(status_code=400, detail="Invalid state format")
# Get session data from storage if not oauth_session:
session = oauth_provider.storage.get_session(session_id) logger.error(f"OAuth session not found for ID: {session_id}")
if not session: return JSONResponse(
logger.error(f"Session {session_id} not found") status_code=400,
raise HTTPException(status_code=400, detail="Invalid session") content={"error": "invalid_request", "error_description": "OAuth session not found"}
# 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 # Generate authorization code
# The "code" here is our session ID that Claude will exchange for a token auth_code = f"clerk_{session.id}_{session_id}"
# Store the code mapping for token exchange
code_data = {
"session_id": session_id,
"clerk_session_id": session.id if hasattr(session, 'id') else None,
"user_id": session.user_id if hasattr(session, 'user_id') else None,
"created_at": time.time(),
"expires_at": (datetime.utcnow() + timedelta(minutes=5)).timestamp(),
}
oauth_provider.storage.set_session(f"code_{auth_code}", code_data)
# Build redirect URL back to Claude
redirect_params = { redirect_params = {
"code": session_id, "code": auth_code,
"state": original_state "state": original_state
} }
redirect_url = f"{session['redirect_uri']}?{urlencode(redirect_params)}" redirect_url = f"{oauth_session['redirect_uri']}?{urlencode(redirect_params)}"
logger.info(f"Redirecting back to Claude: {redirect_url}") logger.info(f"Redirecting back to Claude: {redirect_url}")
return RedirectResponse(url=redirect_url) return RedirectResponse(url=redirect_url)
except Exception as e: except Exception as e:
logger.exception(f"Callback processing failed: {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( return JSONResponse(
status_code=500, status_code=500,
content={"error": "server_error", "error_description": str(e)} content={"error": "server_error", "error_description": str(e)}
@@ -227,8 +336,22 @@ async def token_endpoint(request: Request):
if not oauth_provider: if not oauth_provider:
raise HTTPException(status_code=500, detail="OAuth provider not configured") raise HTTPException(status_code=500, detail="OAuth provider not configured")
# The "code" is actually our session ID # Extract session info from code
code_session = None
if code.startswith("clerk_"):
# Get the code mapping
code_session = oauth_provider.storage.get_session(f"code_{code}")
if code_session:
session_id = code_session.get("session_id")
else:
logger.error(f"Code mapping not found for: {code}")
return JSONResponse(
status_code=400,
content={"error": "invalid_grant", "error_description": "Invalid authorization code"}
)
else:
session_id = code session_id = code
session = oauth_provider.storage.get_session(session_id) session = oauth_provider.storage.get_session(session_id)
if not session: if not session:
@@ -238,17 +361,18 @@ async def token_endpoint(request: Request):
content={"error": "invalid_grant", "error_description": "Invalid authorization code"} content={"error": "invalid_grant", "error_description": "Invalid authorization code"}
) )
# Validate PKCE # Validate PKCE if present
if "pkce_verifier" in session: if "pkce_challenge" in session and code_verifier:
# Session has the verifier stored, validate it matches # Validate PKCE challenge
if code_verifier != session["pkce_verifier"]: if not oauth_provider.validate_pkce(code_verifier, session["pkce_challenge"]):
logger.error("PKCE verifier mismatch") logger.error("PKCE challenge validation failed")
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
content={"error": "invalid_grant", "error_description": "Invalid code verifier"} content={"error": "invalid_grant", "error_description": "Invalid code verifier"}
) )
logger.info("PKCE validation successful")
else: else:
logger.warning("No PKCE verifier in session, skipping validation") logger.info("No PKCE validation required")
# Create JWT token # Create JWT token
access_token = oauth_provider._create_mcp_token( access_token = oauth_provider._create_mcp_token(
@@ -257,8 +381,10 @@ async def token_endpoint(request: Request):
session_id session_id
) )
# Clean up session # Clean up sessions
oauth_provider.storage.delete_session(session_id) oauth_provider.storage.delete_session(session_id)
if code_session:
oauth_provider.storage.delete_session(f"code_{code}")
return JSONResponse({ return JSONResponse({
"access_token": access_token, "access_token": access_token,
+1
View File
@@ -31,6 +31,7 @@ production = [
"uvicorn[standard]>=0.30.0", "uvicorn[standard]>=0.30.0",
] ]
saas = [ saas = [
"clerk-backend-api>=3.0.0",
"stripe>=9.1.0", "stripe>=9.1.0",
] ]