From 53e555f10c50e4d6d323966a0a892e8d04943568 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:08:23 +0530 Subject: [PATCH] feat: implement Composio re-authentication endpoints and refresh logic - Added new endpoints for re-authenticating Composio connectors, allowing users to refresh their authentication when expired. - Introduced a method in ComposioService to handle the refresh of connected accounts. - Updated the frontend to support re-authentication flows, including success notifications and redirection after re-authentication. - Enhanced error handling for re-authentication processes to improve user experience. --- .../app/routes/composio_routes.py | 181 ++++++++++++++++++ .../app/services/composio_service.py | 31 +++ .../views/connector-accounts-list-view.tsx | 6 + 3 files changed, 218 insertions(+) diff --git a/surfsense_backend/app/routes/composio_routes.py b/surfsense_backend/app/routes/composio_routes.py index 61076c666..721bd458e 100644 --- a/surfsense_backend/app/routes/composio_routes.py +++ b/surfsense_backend/app/routes/composio_routes.py @@ -425,6 +425,187 @@ async def composio_callback( ) from e +COMPOSIO_CONNECTOR_TYPES = { + SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR, +} + + +@router.get("/auth/composio/connector/reauth") +async def reauth_composio_connector( + space_id: int, + connector_id: int, + return_url: str | None = None, + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +): + """ + Initiate Composio re-authentication for an expired connected account. + + Uses Composio's refresh API so the same connected_account_id stays valid + after the user completes the OAuth flow again. + + Query params: + space_id: Search space ID the connector belongs to + connector_id: ID of the existing Composio connector to re-authenticate + return_url: Optional frontend path to redirect to after completion + """ + if not ComposioService.is_enabled(): + raise HTTPException(status_code=503, detail="Composio integration is not enabled.") + + if not config.SECRET_KEY: + raise HTTPException(status_code=500, detail="SECRET_KEY not configured for OAuth security.") + + try: + result = await session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == connector_id, + SearchSourceConnector.user_id == user.id, + SearchSourceConnector.search_space_id == space_id, + SearchSourceConnector.connector_type.in_(COMPOSIO_CONNECTOR_TYPES), + ) + ) + connector = result.scalars().first() + if not connector: + raise HTTPException( + status_code=404, + detail="Composio connector not found or access denied", + ) + + connected_account_id = connector.config.get("composio_connected_account_id") + if not connected_account_id: + raise HTTPException( + status_code=400, + detail="Composio connected account ID not found. Please reconnect the connector.", + ) + + # Build callback URL with secure state + state_manager = get_state_manager() + state_encoded = state_manager.generate_secure_state( + space_id, + user.id, + toolkit_id=connector.config.get("toolkit_id", ""), + connector_id=connector_id, + return_url=return_url, + ) + + callback_base = config.COMPOSIO_REDIRECT_URI + if not callback_base: + backend_url = config.BACKEND_URL or "http://localhost:8000" + callback_base = f"{backend_url}/api/v1/auth/composio/connector/reauth/callback" + else: + # Replace the normal callback path with the reauth one + callback_base = callback_base.replace( + "/auth/composio/connector/callback", + "/auth/composio/connector/reauth/callback", + ) + + callback_url = f"{callback_base}?state={state_encoded}" + + service = ComposioService() + refresh_result = service.refresh_connected_account( + connected_account_id=connected_account_id, + redirect_url=callback_url, + ) + + if refresh_result["redirect_url"] is None: + # Token refreshed server-side; clear auth_expired immediately + if connector.config.get("auth_expired"): + connector.config = {**connector.config, "auth_expired": False} + flag_modified(connector, "config") + await session.commit() + logger.info(f"Composio account {connected_account_id} refreshed server-side (no redirect needed)") + return {"success": True, "message": "Authentication refreshed successfully."} + + logger.info(f"Initiating Composio re-auth for connector {connector_id}") + return {"auth_url": refresh_result["redirect_url"]} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to initiate Composio re-auth: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to initiate Composio re-auth: {e!s}" + ) from e + + +@router.get("/auth/composio/connector/reauth/callback") +async def composio_reauth_callback( + request: Request, + state: str | None = None, + session: AsyncSession = Depends(get_async_session), +): + """ + Handle Composio re-authentication callback. + + Clears the auth_expired flag and redirects the user back to the frontend. + The connected_account_id has not changed — Composio refreshed it in place. + """ + try: + if not state: + raise HTTPException(status_code=400, detail="Missing state parameter") + + state_manager = get_state_manager() + try: + data = state_manager.validate_state(state) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=400, detail=f"Invalid state parameter: {e!s}" + ) from e + + user_id = UUID(data["user_id"]) + space_id = data["space_id"] + reauth_connector_id = data.get("connector_id") + return_url = data.get("return_url") + + if not reauth_connector_id: + raise HTTPException(status_code=400, detail="Missing connector_id in state") + + result = await session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == reauth_connector_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.search_space_id == space_id, + ) + ) + connector = result.scalars().first() + if not connector: + raise HTTPException( + status_code=404, + detail="Connector not found or access denied during re-auth callback", + ) + + # Clear auth_expired flag + if connector.config.get("auth_expired"): + connector.config = {**connector.config, "auth_expired": False} + flag_modified(connector, "config") + await session.commit() + await session.refresh(connector) + + logger.info(f"Composio re-auth completed for connector {reauth_connector_id}") + + if return_url and return_url.startswith("/"): + return RedirectResponse(url=f"{config.NEXT_FRONTEND_URL}{return_url}") + + frontend_connector_id = TOOLKIT_TO_FRONTEND_CONNECTOR_ID.get( + connector.config.get("toolkit_id", ""), "composio-connector" + ) + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/callback?success=true&connector={frontend_connector_id}&connectorId={reauth_connector_id}" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error in Composio reauth callback: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to complete Composio re-auth: {e!s}" + ) from e + + @router.get("/connectors/{connector_id}/composio-drive/folders") async def list_composio_drive_folders( connector_id: int, diff --git a/surfsense_backend/app/services/composio_service.py b/surfsense_backend/app/services/composio_service.py index 7c20be9a4..8f3db1efd 100644 --- a/surfsense_backend/app/services/composio_service.py +++ b/surfsense_backend/app/services/composio_service.py @@ -229,6 +229,37 @@ class ComposioService: ) return False + def refresh_connected_account( + self, + connected_account_id: str, + redirect_url: str | None = None, + ) -> dict[str, Any]: + """ + Refresh an expired Composio connected account. + + For OAuth flows this returns a redirect_url the user must visit to + re-authorise. The same connected_account_id stays valid afterwards. + + Args: + connected_account_id: The Composio connected account nanoid. + redirect_url: Where Composio should redirect after re-auth. + + Returns: + Dict with id, status, and redirect_url (None when no redirect needed). + """ + kwargs: dict[str, Any] = {} + if redirect_url is not None: + kwargs["body_redirect_url"] = redirect_url + result = self.client.connected_accounts.refresh( + nanoid=connected_account_id, + **kwargs, + ) + return { + "id": result.id, + "status": result.status, + "redirect_url": result.redirect_url, + } + def get_access_token(self, connected_account_id: str) -> str: """Retrieve the raw OAuth access token for a Composio connected account.""" account = self.client.connected_accounts.get(nanoid=connected_account_id) diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx index 25e2cf8e5..8d2182c16 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx @@ -22,6 +22,9 @@ const REAUTH_ENDPOINTS: Partial> = { [EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/google/drive/connector/reauth", [EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: "/api/v1/auth/google/gmail/connector/reauth", [EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/google/calendar/connector/reauth", + [EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth", + [EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth", + [EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth", }; interface ConnectorAccountsListViewProps { @@ -85,6 +88,9 @@ export const ConnectorAccountsListView: FC = ({ const data = await response.json(); if (data.auth_url) { window.location.href = data.auth_url; + } else if (data.success) { + toast.success(data.message ?? "Authentication refreshed successfully."); + window.location.reload(); } } catch { toast.error("Failed to initiate re-authentication.");