diff --git a/surfsense_backend/app/connectors/airtable_history.py b/surfsense_backend/app/connectors/airtable_history.py index 64f6465fe..3c1c8b1cb 100644 --- a/surfsense_backend/app/connectors/airtable_history.py +++ b/surfsense_backend/app/connectors/airtable_history.py @@ -12,7 +12,7 @@ from sqlalchemy.future import select from app.config import config from app.connectors.airtable_connector import AirtableConnector from app.db import SearchSourceConnector -from app.routes.airtable_add_connector_route import refresh_airtable_token +from app.utils.airtable_token_utils import refresh_airtable_token from app.schemas.airtable_auth_credentials import AirtableAuthCredentialsBase from app.utils.oauth_security import TokenEncryption diff --git a/surfsense_backend/app/connectors/clickup_history.py b/surfsense_backend/app/connectors/clickup_history.py index 70e90028b..6c28e935a 100644 --- a/surfsense_backend/app/connectors/clickup_history.py +++ b/surfsense_backend/app/connectors/clickup_history.py @@ -14,10 +14,10 @@ from sqlalchemy.future import select from app.config import config from app.connectors.clickup_connector import ClickUpConnector from app.db import SearchSourceConnector -from app.routes.clickup_add_connector_route import refresh_clickup_token from app.schemas.clickup_auth_credentials import ClickUpAuthCredentialsBase from app.utils.oauth_security import TokenEncryption + logger = logging.getLogger(__name__) @@ -184,6 +184,8 @@ class ClickUpHistoryConnector: ) # Refresh token + # Lazy import to avoid circular dependency + from app.routes.clickup_add_connector_route import refresh_clickup_token connector = await refresh_clickup_token(self._session, connector) # Reload credentials after refresh diff --git a/surfsense_backend/app/connectors/confluence_history.py b/surfsense_backend/app/connectors/confluence_history.py index 9e10ffcf1..bd947da3d 100644 --- a/surfsense_backend/app/connectors/confluence_history.py +++ b/surfsense_backend/app/connectors/confluence_history.py @@ -14,7 +14,6 @@ from sqlalchemy.future import select from app.config import config from app.connectors.confluence_connector import ConfluenceConnector from app.db import SearchSourceConnector -from app.routes.confluence_add_connector_route import refresh_confluence_token from app.schemas.atlassian_auth_credentials import AtlassianAuthCredentialsBase from app.utils.oauth_security import TokenEncryption @@ -173,6 +172,8 @@ class ConfluenceHistoryConnector: ) # Refresh token + # Lazy import to avoid circular dependency + from app.routes.confluence_add_connector_route import refresh_confluence_token connector = await refresh_confluence_token(self._session, connector) # Reload credentials after refresh diff --git a/surfsense_backend/app/connectors/discord_connector.py b/surfsense_backend/app/connectors/discord_connector.py index 1e12cb9a4..5d06ac0c9 100644 --- a/surfsense_backend/app/connectors/discord_connector.py +++ b/surfsense_backend/app/connectors/discord_connector.py @@ -17,7 +17,6 @@ from sqlalchemy.future import select from app.config import config from app.db import SearchSourceConnector -from app.routes.discord_add_connector_route import refresh_discord_token from app.schemas.discord_auth_credentials import DiscordAuthCredentialsBase from app.utils.oauth_security import TokenEncryption @@ -174,6 +173,8 @@ class DiscordConnector(commands.Bot): ) # Refresh token + # Lazy import to avoid circular dependency + from app.routes.discord_add_connector_route import refresh_discord_token connector = await refresh_discord_token(self._session, connector) # Reload credentials after refresh diff --git a/surfsense_backend/app/connectors/jira_history.py b/surfsense_backend/app/connectors/jira_history.py index 6e04ec2a4..7fdbda7a4 100644 --- a/surfsense_backend/app/connectors/jira_history.py +++ b/surfsense_backend/app/connectors/jira_history.py @@ -14,7 +14,6 @@ from sqlalchemy.future import select from app.config import config from app.connectors.jira_connector import JiraConnector from app.db import SearchSourceConnector -from app.routes.jira_add_connector_route import refresh_jira_token from app.schemas.atlassian_auth_credentials import AtlassianAuthCredentialsBase from app.utils.oauth_security import TokenEncryption @@ -167,6 +166,8 @@ class JiraHistoryConnector: ) # Refresh token + # Lazy import to avoid circular dependency + from app.routes.jira_add_connector_route import refresh_jira_token connector = await refresh_jira_token(self._session, connector) # Reload credentials after refresh diff --git a/surfsense_backend/app/connectors/notion_history.py b/surfsense_backend/app/connectors/notion_history.py index ff8478905..ab846a400 100644 --- a/surfsense_backend/app/connectors/notion_history.py +++ b/surfsense_backend/app/connectors/notion_history.py @@ -10,7 +10,6 @@ from sqlalchemy.future import select from app.config import config from app.db import SearchSourceConnector -from app.routes.notion_add_connector_route import refresh_notion_token from app.schemas.notion_auth_credentials import NotionAuthCredentialsBase from app.utils.oauth_security import TokenEncryption @@ -213,6 +212,8 @@ class NotionHistoryConnector: ) # Refresh token + # Lazy import to avoid circular dependency + from app.routes.notion_add_connector_route import refresh_notion_token connector = await refresh_notion_token(self._session, connector) # Reload credentials after refresh diff --git a/surfsense_backend/app/connectors/slack_history.py b/surfsense_backend/app/connectors/slack_history.py index 2b36b9f96..540403558 100644 --- a/surfsense_backend/app/connectors/slack_history.py +++ b/surfsense_backend/app/connectors/slack_history.py @@ -17,7 +17,6 @@ from sqlalchemy.future import select from app.config import config from app.db import SearchSourceConnector -from app.routes.slack_add_connector_route import refresh_slack_token from app.schemas.slack_auth_credentials import SlackAuthCredentialsBase from app.utils.oauth_security import TokenEncryption @@ -155,6 +154,8 @@ class SlackHistory: ) # Refresh token + # Lazy import to avoid circular dependency + from app.routes.slack_add_connector_route import refresh_slack_token connector = await refresh_slack_token(self._session, connector) # Reload credentials after refresh diff --git a/surfsense_backend/app/connectors/teams_connector.py b/surfsense_backend/app/connectors/teams_connector.py index c639ab177..baa820a85 100644 --- a/surfsense_backend/app/connectors/teams_connector.py +++ b/surfsense_backend/app/connectors/teams_connector.py @@ -16,7 +16,6 @@ from sqlalchemy.future import select from app.config import config from app.db import SearchSourceConnector -from app.routes.teams_add_connector_route import refresh_teams_token from app.schemas.teams_auth_credentials import TeamsAuthCredentialsBase from app.utils.oauth_security import TokenEncryption @@ -146,6 +145,8 @@ class TeamsConnector: ) # Refresh token + # Lazy import to avoid circular dependency + from app.routes.teams_add_connector_route import refresh_teams_token connector = await refresh_teams_token(self._session, connector) # Reload credentials after refresh diff --git a/surfsense_backend/app/routes/airtable_add_connector_route.py b/surfsense_backend/app/routes/airtable_add_connector_route.py index 64fa104d8..95e056787 100644 --- a/surfsense_backend/app/routes/airtable_add_connector_route.py +++ b/surfsense_backend/app/routes/airtable_add_connector_route.py @@ -22,6 +22,7 @@ from app.db import ( ) from app.schemas.airtable_auth_credentials import AirtableAuthCredentialsBase from app.users import current_active_user +from app.utils.airtable_token_utils import refresh_airtable_token from app.utils.connector_naming import ( check_duplicate_connector, generate_unique_connector_name, @@ -378,121 +379,3 @@ async def airtable_callback( status_code=500, detail=f"Failed to complete Airtable OAuth: {e!s}" ) from e - -async def refresh_airtable_token( - session: AsyncSession, connector: SearchSourceConnector -) -> SearchSourceConnector: - """ - Refresh the Airtable access token for a connector. - - Args: - session: Database session - connector: Airtable connector to refresh - - Returns: - Updated connector object - """ - try: - logger.info(f"Refreshing Airtable token for connector {connector.id}") - - credentials = AirtableAuthCredentialsBase.from_dict(connector.config) - - # Decrypt tokens if they are encrypted - token_encryption = get_token_encryption() - is_encrypted = connector.config.get("_token_encrypted", False) - - refresh_token = credentials.refresh_token - if is_encrypted and refresh_token: - try: - refresh_token = token_encryption.decrypt_token(refresh_token) - except Exception as e: - logger.error(f"Failed to decrypt refresh token: {e!s}") - raise HTTPException( - status_code=500, detail="Failed to decrypt stored refresh token" - ) from e - - if not refresh_token: - raise HTTPException( - status_code=400, - detail="No refresh token available. Please re-authenticate.", - ) - - auth_header = make_basic_auth_header( - config.AIRTABLE_CLIENT_ID, config.AIRTABLE_CLIENT_SECRET - ) - - # Prepare token refresh data - refresh_data = { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": config.AIRTABLE_CLIENT_ID, - "client_secret": config.AIRTABLE_CLIENT_SECRET, - } - - async with httpx.AsyncClient() as client: - token_response = await client.post( - TOKEN_URL, - data=refresh_data, - headers={ - "Content-Type": "application/x-www-form-urlencoded", - "Authorization": auth_header, - }, - timeout=30.0, - ) - - if token_response.status_code != 200: - error_detail = token_response.text - try: - error_json = token_response.json() - error_detail = error_json.get("error_description", error_detail) - except Exception: - pass - raise HTTPException( - status_code=400, detail=f"Token refresh failed: {error_detail}" - ) - - token_json = token_response.json() - - # Calculate expiration time (UTC, tz-aware) - expires_at = None - if token_json.get("expires_in"): - now_utc = datetime.now(UTC) - expires_at = now_utc + timedelta(seconds=int(token_json["expires_in"])) - - # Encrypt new tokens before storing - access_token = token_json.get("access_token") - new_refresh_token = token_json.get("refresh_token") - - if not access_token: - raise HTTPException( - status_code=400, detail="No access token received from Airtable refresh" - ) - - # Update credentials object with encrypted tokens - credentials.access_token = token_encryption.encrypt_token(access_token) - if new_refresh_token: - credentials.refresh_token = token_encryption.encrypt_token( - new_refresh_token - ) - credentials.expires_in = token_json.get("expires_in") - credentials.expires_at = expires_at - credentials.scope = token_json.get("scope") - - # Update connector config with encrypted tokens - credentials_dict = credentials.to_dict() - credentials_dict["_token_encrypted"] = True - connector.config = credentials_dict - await session.commit() - await session.refresh(connector) - - logger.info( - f"Successfully refreshed Airtable token for connector {connector.id}" - ) - - return connector - except HTTPException: - raise - except Exception as e: - raise HTTPException( - status_code=500, detail=f"Failed to refresh Airtable token: {e!s}" - ) from e diff --git a/surfsense_backend/app/utils/airtable_token_utils.py b/surfsense_backend/app/utils/airtable_token_utils.py new file mode 100644 index 000000000..49f8c8ddd --- /dev/null +++ b/surfsense_backend/app/utils/airtable_token_utils.py @@ -0,0 +1,157 @@ +""" +Airtable token refresh utilities. + +This module contains shared utilities for refreshing Airtable OAuth tokens. +Extracted from routes to avoid circular imports. +""" + +import base64 +import logging +from datetime import UTC, datetime, timedelta + +import httpx +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import config +from app.db import SearchSourceConnector +from app.schemas.airtable_auth_credentials import AirtableAuthCredentialsBase +from app.utils.oauth_security import TokenEncryption + +logger = logging.getLogger(__name__) + +# Airtable OAuth token endpoint +TOKEN_URL = "https://airtable.com/oauth2/v1/token" + + +def make_basic_auth_header(client_id: str, client_secret: str) -> str: + """Create HTTP Basic authentication header.""" + credentials = f"{client_id}:{client_secret}".encode() + b64 = base64.b64encode(credentials).decode("ascii") + return f"Basic {b64}" + + +def get_token_encryption() -> TokenEncryption: + """Get or create token encryption instance.""" + if not config.SECRET_KEY: + raise ValueError("SECRET_KEY must be set for token encryption") + return TokenEncryption(config.SECRET_KEY) + + +async def refresh_airtable_token( + session: AsyncSession, connector: SearchSourceConnector +) -> SearchSourceConnector: + """ + Refresh the Airtable access token for a connector. + + Args: + session: Database session + connector: Airtable connector to refresh + + Returns: + Updated connector object + """ + try: + logger.info(f"Refreshing Airtable token for connector {connector.id}") + + credentials = AirtableAuthCredentialsBase.from_dict(connector.config) + + # Decrypt tokens if they are encrypted + token_encryption = get_token_encryption() + is_encrypted = connector.config.get("_token_encrypted", False) + + refresh_token = credentials.refresh_token + if is_encrypted and refresh_token: + try: + refresh_token = token_encryption.decrypt_token(refresh_token) + except Exception as e: + logger.error(f"Failed to decrypt refresh token: {e!s}") + raise HTTPException( + status_code=500, detail="Failed to decrypt stored refresh token" + ) from e + + if not refresh_token: + raise HTTPException( + status_code=400, + detail="No refresh token available. Please re-authenticate.", + ) + + auth_header = make_basic_auth_header( + config.AIRTABLE_CLIENT_ID, config.AIRTABLE_CLIENT_SECRET + ) + + # Prepare token refresh data + refresh_data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": config.AIRTABLE_CLIENT_ID, + "client_secret": config.AIRTABLE_CLIENT_SECRET, + } + + async with httpx.AsyncClient() as client: + token_response = await client.post( + TOKEN_URL, + data=refresh_data, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": auth_header, + }, + timeout=30.0, + ) + + if token_response.status_code != 200: + error_detail = token_response.text + try: + error_json = token_response.json() + error_detail = error_json.get("error_description", error_detail) + except Exception: + pass + raise HTTPException( + status_code=400, detail=f"Token refresh failed: {error_detail}" + ) + + token_json = token_response.json() + + # Calculate expiration time (UTC, tz-aware) + expires_at = None + if token_json.get("expires_in"): + now_utc = datetime.now(UTC) + expires_at = now_utc + timedelta(seconds=int(token_json["expires_in"])) + + # Encrypt new tokens before storing + access_token = token_json.get("access_token") + new_refresh_token = token_json.get("refresh_token") + + if not access_token: + raise HTTPException( + status_code=400, detail="No access token received from Airtable refresh" + ) + + # Update credentials object with encrypted tokens + credentials.access_token = token_encryption.encrypt_token(access_token) + if new_refresh_token: + credentials.refresh_token = token_encryption.encrypt_token( + new_refresh_token + ) + credentials.expires_in = token_json.get("expires_in") + credentials.expires_at = expires_at + credentials.scope = token_json.get("scope") + + # Update connector config with encrypted tokens + credentials_dict = credentials.to_dict() + credentials_dict["_token_encrypted"] = True + connector.config = credentials_dict + await session.commit() + await session.refresh(connector) + + logger.info( + f"Successfully refreshed Airtable token for connector {connector.id}" + ) + + return connector + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to refresh Airtable token: {e!s}" + ) from e