gateway/modules/routes/routeSecurityClickup.py
2026-04-10 12:33:27 +02:00

282 lines
10 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""ClickUp OAuth for data connections (UserConnection + Token)."""
from fastapi import APIRouter, HTTPException, Request, status, Depends, Query
from fastapi.responses import HTMLResponse, RedirectResponse
import logging
import json
import time
from typing import Dict, Any
from urllib.parse import urlencode
import httpx
from jose import jwt as jose_jwt
from jose import JWTError
from modules.shared.configuration import APP_CONFIG
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeSecurityClickup")
logger = logging.getLogger(__name__)
_FLOW_CONNECT = "clickup_connect"
CLICKUP_AUTH_BASE = "https://app.clickup.com/api"
CLICKUP_API_BASE = "https://api.clickup.com/api/v2"
CLIENT_ID = APP_CONFIG.get("Service_CLICKUP_CLIENT_ID")
CLIENT_SECRET = APP_CONFIG.get("Service_CLICKUP_CLIENT_SECRET")
REDIRECT_URI = APP_CONFIG.get("Service_CLICKUP_OAUTH_REDIRECT_URI")
# ClickUp states OAuth access tokens do not expire today; store a long horizon for DB status.
_CLICKUP_TOKEN_EXPIRES_IN_SEC = 10 * 365 * 24 * 3600
def _issue_oauth_state(claims: Dict[str, Any]) -> str:
body = {**claims, "exp": int(time.time()) + 600}
return jose_jwt.encode(body, SECRET_KEY, algorithm=ALGORITHM)
def _parse_oauth_state(state: str) -> Dict[str, Any]:
try:
return jose_jwt.decode(state, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid OAuth state: {e}"
) from e
def _require_clickup_config():
if not CLIENT_ID or not CLIENT_SECRET or not REDIRECT_URI:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=routeApiMsg("ClickUp OAuth is not configured (Service_CLICKUP_CLIENT_ID, Service_CLICKUP_CLIENT_SECRET, Service_CLICKUP_OAUTH_REDIRECT_URI)"),
)
router = APIRouter(
prefix="/api/clickup",
tags=["Security ClickUp"],
responses={
404: {"description": "Not found"},
400: {"description": "Bad request"},
401: {"description": "Unauthorized"},
500: {"description": "Internal server error"},
},
)
@router.get("/auth/connect")
@limiter.limit("5/minute")
def auth_connect(
request: Request,
connectionId: str = Query(..., description="UserConnection id"),
currentUser: User = Depends(getCurrentUser),
) -> RedirectResponse:
"""Start ClickUp OAuth for an existing connection (requires gateway session)."""
try:
_require_clickup_config()
interface = getInterface(currentUser)
connections = interface.getUserConnections(currentUser.id)
connection = None
for conn in connections:
if conn.id == connectionId and conn.authority == AuthAuthority.CLICKUP:
connection = conn
break
if not connection:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("ClickUp connection not found"))
state_jwt = _issue_oauth_state(
{
"flow": _FLOW_CONNECT,
"connectionId": connectionId,
"userId": str(currentUser.id),
}
)
query = urlencode(
{
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"state": state_jwt,
}
)
auth_url = f"{CLICKUP_AUTH_BASE}?{query}"
return RedirectResponse(auth_url)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error initiating ClickUp connect: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to initiate ClickUp connect: {str(e)}",
)
@router.get("/auth/connect/callback")
async def auth_connect_callback(
code: str = Query(...),
state: str = Query(...),
) -> HTMLResponse:
"""OAuth callback for ClickUp data connection."""
state_data = _parse_oauth_state(state)
if state_data.get("flow") != _FLOW_CONNECT:
raise HTTPException(status_code=400, detail=routeApiMsg("Invalid OAuth flow for this callback"))
connection_id = state_data.get("connectionId")
user_id = state_data.get("userId")
if not connection_id or not user_id:
raise HTTPException(status_code=400, detail=routeApiMsg("Missing connection or user in OAuth state"))
_require_clickup_config()
async with httpx.AsyncClient() as client:
token_resp = await client.post(
f"{CLICKUP_API_BASE}/oauth/token",
json={
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"code": code,
},
headers={"Content-Type": "application/json"},
timeout=30.0,
)
if token_resp.status_code != 200:
logger.error(f"ClickUp token exchange failed: {token_resp.status_code} {token_resp.text}")
return HTMLResponse(
content=f"<html><body><h1>Connection Failed</h1><p>{token_resp.text}</p></body></html>",
status_code=400,
)
token_json = token_resp.json()
access_token = token_json.get("access_token")
if not access_token:
return HTMLResponse(
content="<html><body><h1>Connection Failed</h1><p>No access token.</p></body></html>",
status_code=400,
)
async with httpx.AsyncClient() as client:
user_resp = await client.get(
f"{CLICKUP_API_BASE}/user",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
timeout=30.0,
)
if user_resp.status_code != 200:
logger.error(f"ClickUp user failed: {user_resp.status_code} {user_resp.text}")
return HTMLResponse(
content="<html><body><h1>Connection Failed</h1><p>Could not load ClickUp user.</p></body></html>",
status_code=400,
)
user_payload = user_resp.json()
cu_user = user_payload.get("user") or {}
rootInterface = getRootInterface()
user = rootInterface.getUser(user_id)
if not user:
return HTMLResponse(
content="""
<html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'clickup_connection_error', error: 'User not found' }, '*');
setTimeout(() => window.close(), 1000);
} else window.close();
</script></body></html>
""",
status_code=404,
)
interface = getInterface(user)
connections = interface.getUserConnections(user_id)
connection = None
for conn in connections:
if conn.id == connection_id:
connection = conn
break
if not connection:
return HTMLResponse(
content="""
<html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'clickup_connection_error', error: 'Connection not found' }, '*');
setTimeout(() => window.close(), 1000);
} else window.close();
</script></body></html>
""",
status_code=404,
)
ext_id = str(cu_user.get("id", "")) if cu_user.get("id") is not None else ""
username = cu_user.get("username") or cu_user.get("email") or ext_id
email = cu_user.get("email")
expires_at = createExpirationTimestamp(_CLICKUP_TOKEN_EXPIRES_IN_SEC)
try:
connection.status = ConnectionStatus.ACTIVE
connection.lastChecked = getUtcTimestamp()
connection.expiresAt = expires_at
connection.externalId = ext_id
connection.externalUsername = username
if email:
connection.externalEmail = email
connection.grantedScopes = None
rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump())
token = Token(
userId=user.id,
authority=AuthAuthority.CLICKUP,
connectionId=connection_id,
tokenPurpose=TokenPurpose.DATA_CONNECTION,
tokenAccess=access_token,
tokenRefresh=None,
tokenType="bearer",
expiresAt=expires_at,
createdAt=getUtcTimestamp(),
)
interface.saveConnectionToken(token)
return HTMLResponse(
content=f"""
<html>
<head><title>Connection Successful</title></head>
<body>
<script>
if (window.opener) {{
window.opener.postMessage({{
type: 'clickup_connection_success',
connection: {{
id: '{connection.id}',
status: 'connected',
type: 'clickup',
lastChecked: {getUtcTimestamp()},
expiresAt: {expires_at}
}}
}}, '*');
setTimeout(() => window.close(), 1000);
}} else {{
window.close();
}}
</script>
</body>
</html>
"""
)
except Exception as e:
logger.error(f"Error updating ClickUp connection: {str(e)}", exc_info=True)
return HTMLResponse(
content=f"""
<html><body><script>
if (window.opener) {{
window.opener.postMessage({{ type: 'clickup_connection_error', error: {json.dumps(str(e))} }}, '*');
setTimeout(() => window.close(), 1000);
}} else window.close();
</script></body></html>
""",
status_code=500,
)