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:
"""Create OAuth configuration for Clerk integration"""
"""Create OAuth configuration for Clerk integration using SDK"""
# Get Clerk configuration from environment
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:
raise ValueError("CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY are required")
# Determine if custom domain or standard Clerk domain
if '.' in clerk_domain and not clerk_domain.endswith('.accounts.dev'):
# Custom domain like accounts.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"
# For Clerk with custom domains, we use our adapter endpoints
# This allows us to handle the custom domain flow properly
base_url = os.getenv("BASE_URL", "https://yargimcp.com")
config = OAuthConfig(
client_id=clerk_publishable_key,
client_secret=clerk_secret_key,
authorization_endpoint=f"{base_url}/oauth/authorize",
token_endpoint=f"{base_url}/oauth/token",
jwks_uri=f"{base_url}/.well-known/jwks.json",
issuer=issuer,
# Use our adapter endpoints instead of Clerk's direct endpoints
authorization_endpoint=f"{base_url}/authorize",
token_endpoint=f"{base_url}/token",
# 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"]
)
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"Token endpoint: {config.token_endpoint}")
+40 -11
View File
@@ -18,6 +18,14 @@ from jwt.exceptions import PyJWTError, InvalidTokenError
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__)
@@ -62,6 +70,16 @@ class OAuthProvider:
self.jwt_secret = jwt_secret
# Use persistent storage instead of memory
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")
def generate_authorization_url(
@@ -92,19 +110,30 @@ class OAuthProvider:
}
self.storage.set_session(session_id, session_data)
# Build Clerk OAuth URL with PKCE
params = {
"response_type": "code",
"client_id": self.config.client_id,
"redirect_uri": redirect_uri,
"scope": " ".join(scopes),
"state": f"{state}:{session_id}", # Combine state with session ID
"code_challenge": pkce.challenge,
"code_challenge_method": "S256",
}
# 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 = {
"response_type": "code",
"client_id": self.config.client_id,
"redirect_uri": redirect_uri,
"scope": " ".join(scopes),
"state": f"{state}:{session_id}", # Combine state with session ID
"code_challenge": pkce.challenge,
"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.debug(f"Auth URL: {auth_url}")
return auth_url, pkce
async def exchange_code_for_token(
+199 -73
View File
@@ -6,12 +6,26 @@ 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 urllib.parse import urlencode, quote
from datetime import datetime
from fastapi import APIRouter, Request, Query, HTTPException
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__)
router = APIRouter()
@@ -58,32 +72,63 @@ async def authorize_endpoint(
state: 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}")
# 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:
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
)
# Generate session and store PKCE
session_id = secrets.token_urlsafe(32)
if state is None:
state = secrets.token_urlsafe(16)
logger.info(f"Generated auth URL: {auth_url[:100]}...")
# Create PKCE challenge
from mcp_auth.oauth import PKCEChallenge
pkce = PKCEChallenge()
# Redirect to Clerk OAuth
return RedirectResponse(url=auth_url)
# Store session data
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:
logger.exception(f"Authorization failed: {e}")
@@ -92,87 +137,151 @@ async def authorize_endpoint(
@router.get("/auth/callback")
async def oauth_callback(
code: Optional[str] = Query(None),
request: Request,
state: Optional[str] = Query(None),
error: Optional[str] = Query(None),
error_description: Optional[str] = Query(None)
__session: 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:
logger.error(f"OAuth error: {error} - {error_description}")
# Check if we have Clerk SDK
if not CLERK_AVAILABLE:
logger.error("Clerk SDK not available")
return JSONResponse(
status_code=400,
content={"error": error, "error_description": error_description}
status_code=500,
content={"error": "server_error", "error_description": "Clerk SDK not available"}
)
if not code or not state:
logger.error("Missing code or state in callback")
# Get Clerk session from cookies or query params
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(
status_code=400,
content={"error": "invalid_request", "error_description": "Missing code or state parameter"}
status_code=500,
content={"error": "server_error", "error_description": "Clerk not configured"}
)
clerk = Clerk(bearer_auth=clerk_secret)
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_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 stored session
oauth_session = None
if session_id:
oauth_session = oauth_provider.storage.get_session(session_id)
# 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")
if not oauth_session:
logger.error(f"OAuth session not found for ID: {session_id}")
return JSONResponse(
status_code=400,
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"]
)
# Generate authorization code
auth_code = f"clerk_{session.id}_{session_id}"
# Build redirect URL back to Claude with authorization code
# The "code" here is our session ID that Claude will exchange for a token
# 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 = {
"code": session_id,
"code": auth_code,
"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}")
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)}
)
return JSONResponse(
status_code=500,
content={"error": "server_error", "error_description": str(e)}
)
@router.post("/register")
@@ -227,8 +336,22 @@ async def token_endpoint(request: Request):
if not oauth_provider:
raise HTTPException(status_code=500, detail="OAuth provider not configured")
# The "code" is actually our session ID
session_id = code
# 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 = oauth_provider.storage.get_session(session_id)
if not session:
@@ -238,17 +361,18 @@ async def token_endpoint(request: Request):
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")
# Validate PKCE if present
if "pkce_challenge" in session and code_verifier:
# Validate PKCE challenge
if not oauth_provider.validate_pkce(code_verifier, session["pkce_challenge"]):
logger.error("PKCE challenge validation failed")
return JSONResponse(
status_code=400,
content={"error": "invalid_grant", "error_description": "Invalid code verifier"}
)
logger.info("PKCE validation successful")
else:
logger.warning("No PKCE verifier in session, skipping validation")
logger.info("No PKCE validation required")
# Create JWT token
access_token = oauth_provider._create_mcp_token(
@@ -257,8 +381,10 @@ async def token_endpoint(request: Request):
session_id
)
# Clean up session
# Clean up sessions
oauth_provider.storage.delete_session(session_id)
if code_session:
oauth_provider.storage.delete_session(f"code_{code}")
return JSONResponse({
"access_token": access_token,
+1
View File
@@ -31,6 +31,7 @@ production = [
"uvicorn[standard]>=0.30.0",
]
saas = [
"clerk-backend-api>=3.0.0",
"stripe>=9.1.0",
]