Revert "feat: implement Jira OAuth integration and connector routes"

This reverts commit bfed9a31f8.
This commit is contained in:
Anish Sarkar 2026-01-06 00:09:08 +05:30
parent bfed9a31f8
commit f236110a08
14 changed files with 522 additions and 845 deletions

View file

@ -28,7 +28,6 @@ from .search_source_connectors_routes import router as search_source_connectors_
from .search_spaces_routes import router as search_spaces_router
from .slack_add_connector_route import router as slack_add_connector_router
from .discord_add_connector_route import router as discord_add_connector_router
from .jira_add_connector_route import router as jira_add_connector_router
router = APIRouter()
@ -49,7 +48,6 @@ router.include_router(luma_add_connector_router)
router.include_router(notion_add_connector_router)
router.include_router(slack_add_connector_router)
router.include_router(discord_add_connector_router)
router.include_router(jira_add_connector_router)
router.include_router(new_llm_config_router) # LLM configs with prompt configuration
router.include_router(logs_router)
router.include_router(circleback_webhook_router) # Circleback meeting webhooks

View file

@ -1,495 +0,0 @@
"""
Jira Connector OAuth Routes.
Handles OAuth 2.0 authentication flow for Jira connector.
Uses Atlassian OAuth 2.0 (3LO) with accessible-resources API to discover Jira instances.
"""
import logging
from datetime import UTC, datetime, timedelta
from uuid import UUID
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.config import config
from app.db import (
SearchSourceConnector,
SearchSourceConnectorType,
User,
get_async_session,
)
from app.schemas.jira_auth_credentials import JiraAuthCredentialsBase
from app.users import current_active_user
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
logger = logging.getLogger(__name__)
router = APIRouter()
# Atlassian OAuth endpoints
AUTHORIZATION_URL = "https://auth.atlassian.com/authorize"
TOKEN_URL = "https://auth.atlassian.com/oauth/token"
ACCESSIBLE_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources"
# OAuth scopes for Jira
SCOPES = [
"read:jira-work",
"write:jira-work",
"read:jira-user",
"offline_access", # Required for refresh tokens
]
# Initialize security utilities
_state_manager = None
_token_encryption = None
def get_state_manager() -> OAuthStateManager:
"""Get or create OAuth state manager instance."""
global _state_manager
if _state_manager is None:
if not config.SECRET_KEY:
raise ValueError("SECRET_KEY must be set for OAuth security")
_state_manager = OAuthStateManager(config.SECRET_KEY)
return _state_manager
def get_token_encryption() -> TokenEncryption:
"""Get or create token encryption instance."""
global _token_encryption
if _token_encryption is None:
if not config.SECRET_KEY:
raise ValueError("SECRET_KEY must be set for token encryption")
_token_encryption = TokenEncryption(config.SECRET_KEY)
return _token_encryption
@router.get("/auth/jira/connector/add")
async def connect_jira(space_id: int, user: User = Depends(current_active_user)):
"""
Initiate Jira OAuth flow.
Args:
space_id: The search space ID
user: Current authenticated user
Returns:
Authorization URL for redirect
"""
try:
if not space_id:
raise HTTPException(status_code=400, detail="space_id is required")
if not config.JIRA_CLIENT_ID:
raise HTTPException(status_code=500, detail="Jira OAuth not configured.")
if not config.SECRET_KEY:
raise HTTPException(
status_code=500, detail="SECRET_KEY not configured for OAuth security."
)
# Generate secure state parameter with HMAC signature
state_manager = get_state_manager()
state_encoded = state_manager.generate_secure_state(space_id, user.id)
# Build authorization URL
from urllib.parse import urlencode
auth_params = {
"audience": "api.atlassian.com",
"client_id": config.JIRA_CLIENT_ID,
"scope": " ".join(SCOPES),
"redirect_uri": config.JIRA_REDIRECT_URI,
"state": state_encoded,
"response_type": "code",
"prompt": "consent", # Force consent screen to get refresh token
}
auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}"
logger.info(f"Generated Jira OAuth URL for user {user.id}, space {space_id}")
return {"auth_url": auth_url}
except Exception as e:
logger.error(f"Failed to initiate Jira OAuth: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to initiate Jira OAuth: {e!s}"
) from e
@router.get("/auth/jira/connector/callback")
async def jira_callback(
request: Request,
code: str | None = None,
error: str | None = None,
state: str | None = None,
session: AsyncSession = Depends(get_async_session),
):
"""
Handle Jira OAuth callback.
Args:
request: FastAPI request object
code: Authorization code from Atlassian (if user granted access)
error: Error code from Atlassian (if user denied access or error occurred)
state: State parameter containing user/space info
session: Database session
Returns:
Redirect response to frontend
"""
try:
# Handle OAuth errors (e.g., user denied access)
if error:
logger.warning(f"Jira OAuth error: {error}")
# Try to decode state to get space_id for redirect, but don't fail if it's invalid
space_id = None
if state:
try:
state_manager = get_state_manager()
data = state_manager.validate_state(state)
space_id = data.get("space_id")
except Exception:
# If state is invalid, we'll redirect without space_id
logger.warning("Failed to validate state in error handler")
# Redirect to frontend with error parameter
if space_id:
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=jira_oauth_denied"
)
else:
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=jira_oauth_denied"
)
# Validate required parameters for successful flow
if not code:
raise HTTPException(status_code=400, detail="Missing authorization code")
if not state:
raise HTTPException(status_code=400, detail="Missing state parameter")
# Validate and decode state with signature verification
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"]
# Validate redirect URI (security: ensure it matches configured value)
if not config.JIRA_REDIRECT_URI:
raise HTTPException(
status_code=500, detail="JIRA_REDIRECT_URI not configured"
)
# Exchange authorization code for access token
token_data = {
"grant_type": "authorization_code",
"client_id": config.JIRA_CLIENT_ID,
"client_secret": config.JIRA_CLIENT_SECRET,
"code": code,
"redirect_uri": config.JIRA_REDIRECT_URI,
}
async with httpx.AsyncClient() as client:
token_response = await client.post(
TOKEN_URL,
data=token_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
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_json.get("error", error_detail))
except Exception:
pass
raise HTTPException(
status_code=400, detail=f"Token exchange failed: {error_detail}"
)
token_json = token_response.json()
# Encrypt sensitive tokens before storing
token_encryption = get_token_encryption()
access_token = token_json.get("access_token")
refresh_token = token_json.get("refresh_token")
if not access_token:
raise HTTPException(
status_code=400, detail="No access token received from Atlassian"
)
# Fetch accessible resources to get Jira instance information
async with httpx.AsyncClient() as client:
resources_response = await client.get(
ACCESSIBLE_RESOURCES_URL,
headers={"Authorization": f"Bearer {access_token}"},
timeout=30.0,
)
if resources_response.status_code != 200:
error_detail = resources_response.text
logger.error(f"Failed to fetch accessible resources: {error_detail}")
raise HTTPException(
status_code=400,
detail=f"Failed to fetch Jira instances: {error_detail}",
)
resources = resources_response.json()
# Filter for Jira instances (resources with type "jira" or id field)
jira_instances = [
r
for r in resources
if r.get("id") and (r.get("name") or r.get("url"))
]
if not jira_instances:
raise HTTPException(
status_code=400,
detail="No accessible Jira instances found. Please ensure you have access to at least one Jira instance.",
)
# For now, use the first Jira instance
# TODO: Support multiple instances by letting user choose during OAuth
jira_instance = jira_instances[0]
cloud_id = jira_instance["id"]
base_url = jira_instance.get("url")
# If URL is not provided, construct it from cloud_id
if not base_url:
# Try to extract from name or construct default format
instance_name = jira_instance.get("name", "").lower().replace(" ", "")
if instance_name:
base_url = f"https://{instance_name}.atlassian.net"
else:
# Fallback: use cloud_id directly (though this may not work)
base_url = f"https://{cloud_id}.atlassian.net"
# Calculate expiration time (UTC, tz-aware)
expires_at = None
expires_in = token_json.get("expires_in")
if expires_in:
now_utc = datetime.now(UTC)
expires_at = now_utc + timedelta(seconds=int(expires_in))
# Store the encrypted access token and refresh token in connector config
connector_config = {
"access_token": token_encryption.encrypt_token(access_token),
"refresh_token": token_encryption.encrypt_token(refresh_token)
if refresh_token
else None,
"token_type": token_json.get("token_type", "Bearer"),
"expires_in": expires_in,
"expires_at": expires_at.isoformat() if expires_at else None,
"scope": token_json.get("scope"),
"cloud_id": cloud_id,
"base_url": base_url.rstrip("/") if base_url else None,
# Mark that tokens are encrypted for backward compatibility
"_token_encrypted": True,
}
# Check if connector already exists for this search space and user
existing_connector_result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.JIRA_CONNECTOR,
)
)
existing_connector = existing_connector_result.scalars().first()
if existing_connector:
# Update existing connector
existing_connector.config = connector_config
existing_connector.name = "Jira Connector"
existing_connector.is_indexable = True
logger.info(
f"Updated existing Jira connector for user {user_id} in space {space_id}"
)
else:
# Create new connector
new_connector = SearchSourceConnector(
name="Jira Connector",
connector_type=SearchSourceConnectorType.JIRA_CONNECTOR,
is_indexable=True,
config=connector_config,
search_space_id=space_id,
user_id=user_id,
)
session.add(new_connector)
logger.info(
f"Created new Jira connector for user {user_id} in space {space_id}"
)
try:
await session.commit()
logger.info(f"Successfully saved Jira connector for user {user_id}")
# Redirect to the frontend with success params
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=jira-connector"
)
except ValidationError as e:
await session.rollback()
raise HTTPException(
status_code=422, detail=f"Validation error: {e!s}"
) from e
except IntegrityError as e:
await session.rollback()
raise HTTPException(
status_code=409,
detail=f"Integrity error: A connector with this type already exists. {e!s}",
) from e
except Exception as e:
logger.error(f"Failed to create search source connector: {e!s}")
await session.rollback()
raise HTTPException(
status_code=500,
detail=f"Failed to create search source connector: {e!s}",
) from e
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to complete Jira OAuth: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to complete Jira OAuth: {e!s}"
) from e
async def refresh_jira_token(
session: AsyncSession, connector: SearchSourceConnector
) -> SearchSourceConnector:
"""
Refresh the Jira access token for a connector.
Args:
session: Database session
connector: Jira connector to refresh
Returns:
Updated connector object
"""
try:
logger.info(f"Refreshing Jira token for connector {connector.id}")
credentials = JiraAuthCredentialsBase.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.",
)
# Prepare token refresh data
refresh_data = {
"grant_type": "refresh_token",
"client_id": config.JIRA_CLIENT_ID,
"client_secret": config.JIRA_CLIENT_SECRET,
"refresh_token": refresh_token,
}
async with httpx.AsyncClient() as client:
token_response = await client.post(
TOKEN_URL,
data=refresh_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
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_json.get("error", 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
expires_in = token_json.get("expires_in")
if expires_in:
now_utc = datetime.now(UTC)
expires_at = now_utc + timedelta(seconds=int(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 Jira 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 = expires_in
credentials.expires_at = expires_at
credentials.scope = token_json.get("scope")
# Preserve cloud_id and base_url
if not credentials.cloud_id:
credentials.cloud_id = connector.config.get("cloud_id")
if not credentials.base_url:
credentials.base_url = connector.config.get("base_url")
# 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 Jira token for connector {connector.id}")
return connector
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to refresh Jira token: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to refresh Jira token: {e!s}"
) from e