479 lines
18 KiB
Python
479 lines
18 KiB
Python
"""
|
|
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
|
|
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)
|
|
):
|
|
"""
|
|
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"
|
|
})
|
|
|
|
# Check if this is a ChatGPT callback
|
|
original_redirect = redirect_uri or redirect_url
|
|
if "chatgpt.com" in (original_redirect or ""):
|
|
# For ChatGPT, we need to redirect back with the authorization code
|
|
# ChatGPT expects the authorization code to continue the OAuth flow
|
|
return RedirectResponse(
|
|
url=f"{original_redirect}?code={code}&state={state or ''}"
|
|
)
|
|
|
|
# Return the session token to the client for other clients
|
|
# 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 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
|
|
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)}) |