mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-21 18:55:16 +02:00
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:
parent
90481b9462
commit
53e555f10c
3 changed files with 218 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue