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.
This commit is contained in:
Anish Sarkar 2026-03-19 18:08:23 +05:30
parent 90481b9462
commit 53e555f10c
3 changed files with 218 additions and 0 deletions

View file

@ -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,

View file

@ -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)

View file

@ -22,6 +22,9 @@ const REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
[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<ConnectorAccountsListViewProps> = ({
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.");