# 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"

Connection Failed

{token_resp.text}

", status_code=400, ) token_json = token_resp.json() access_token = token_json.get("access_token") if not access_token: return HTMLResponse( content="

Connection Failed

No access token.

", 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="

Connection Failed

Could not load ClickUp user.

", 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=""" """, 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=""" """, 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""" Connection Successful """ ) except Exception as e: logger.error(f"Error updating ClickUp connection: {str(e)}", exc_info=True) return HTMLResponse( content=f""" """, status_code=500, )