diff --git a/app.py b/app.py index 282775ad..2e874678 100644 --- a/app.py +++ b/app.py @@ -153,7 +153,7 @@ async def lifespan(app: FastAPI): # Setup APScheduler for JIRA sync scheduler = AsyncIOScheduler(timezone=ZoneInfo("Europe/Zurich")) try: - from modules.workflow.managerSyncDelta import perform_sync_jira_delta_group + from modules.services.serviceDeltaSync import perform_sync_jira_delta_group # Schedule hourly sync at minute 0 scheduler.add_job( perform_sync_jira_delta_group, diff --git a/modules/interfaces/interfaceChatObjects.py b/modules/interfaces/interfaceChatObjects.py index 43ad3f97..63cec9c7 100644 --- a/modules/interfaces/interfaceChatObjects.py +++ b/modules/interfaces/interfaceChatObjects.py @@ -1155,7 +1155,7 @@ class ChatObjects: # Remove the 'Workflow started' log entry # Start workflow processing - from modules.workflow.managerWorkflow import WorkflowManager + from modules.services.serviceValueonChat import WorkflowManager workflowManager = WorkflowManager(self, currentUser) # Start the workflow processing asynchronously diff --git a/modules/routes/routeSecurityGoogle.py b/modules/routes/routeSecurityGoogle.py index 944f7c0f..a2135a33 100644 --- a/modules/routes/routeSecurityGoogle.py +++ b/modules/routes/routeSecurityGoogle.py @@ -21,6 +21,51 @@ from modules.shared.timezoneUtils import get_utc_now, create_expiration_timestam # Configure logger logger = logging.getLogger(__name__) +async def verify_google_token(access_token: str) -> Dict[str, Any]: + """ + Verify Google access token validity and get token info. + Returns token information including scopes and expiration. + """ + try: + headers = { + 'Authorization': f"Bearer {access_token}", + 'Content-Type': 'application/json' + } + + async with httpx.AsyncClient() as client: + # Use Google's tokeninfo endpoint to verify token + response = await client.get( + "https://www.googleapis.com/oauth2/v1/tokeninfo", + headers=headers, + params={"access_token": access_token} + ) + + if response.status_code == 200: + token_info = response.json() + logger.debug(f"Token verification successful: {token_info.get('email', 'unknown')}") + return { + "valid": True, + "token_info": token_info, + "scopes": token_info.get("scope", "").split(" ") if token_info.get("scope") else [], + "expires_in": int(token_info.get("expires_in", 0)), + "user_id": token_info.get("user_id"), + "email": token_info.get("email") + } + else: + logger.warning(f"Token verification failed: {response.status_code} - {response.text}") + return { + "valid": False, + "error": f"HTTP {response.status_code}", + "details": response.text + } + + except Exception as e: + logger.error(f"Error verifying Google token: {str(e)}") + return { + "valid": False, + "error": str(e) + } + # Create router router = APIRouter( prefix="/api/google", @@ -160,6 +205,20 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse include_client_id=True ) + # Verify which scopes were actually granted (as per Google OAuth 2.0 spec) + granted_scopes = token_data.get("scope", "") + logger.info(f"Granted scopes: {granted_scopes}") + + # Check if all requested scopes were granted + missing_scopes = [] + for requested_scope in SCOPES: + if requested_scope not in granted_scopes: + missing_scopes.append(requested_scope) + + if missing_scopes: + logger.warning(f"Some requested scopes were not granted: {missing_scopes}") + # Continue with available scopes, but log the limitation + token_response = { "access_token": token_data.get("access_token"), "refresh_token": token_data.get("refresh_token", ""), @@ -204,7 +263,16 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse status_code=400 ) - # Get user info using the access token + # Verify the token before proceeding (as per Google OAuth 2.0 spec) + token_verification = await verify_google_token(token_response['access_token']) + if not token_verification.get("valid"): + logger.error(f"Token verification failed: {token_verification.get('error')}") + return HTMLResponse( + content=f"

Authentication Failed

Token verification failed: {token_verification.get('error')}

", + status_code=400 + ) + + # Get user info using the verified access token headers = { 'Authorization': f"Bearer {token_response['access_token']}", 'Content-Type': 'application/json' @@ -222,6 +290,10 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse ) user_info = user_info_response.json() logger.info(f"Got user info from Google: {user_info.get('email')}") + + # Log verified scopes for debugging + verified_scopes = token_verification.get("scopes", []) + logger.info(f"Verified token scopes: {verified_scopes}") if state_type == "login": # Handle login flow @@ -359,7 +431,7 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse connection.externalEmail = user_info.get("email") # Update connection record directly - from modules.interfaces.interfaceAppModel import UserConnection + from modules.interfaces.interfaceAppModel import UserConnection, Token rootInterface.db.recordModify(UserConnection, connection_id, connection.to_dict()) @@ -488,6 +560,72 @@ async def logout( detail=f"Failed to logout: {str(e)}" ) +@router.post("/verify") +@limiter.limit("30/minute") +async def verify_token( + request: Request, + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """Verify current user's Google token validity and get token info""" + try: + appInterface = getInterface(currentUser) + + # Find Google connection for this user + connections = appInterface.getUserConnections(currentUser.id) + google_connection = None + + for conn in connections: + if conn.authority == AuthAuthority.GOOGLE: + google_connection = conn + break + + if not google_connection: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No Google connection found for current user" + ) + + # Get the current token + current_token = appInterface.getConnectionToken(google_connection.id, auto_refresh=False) + + if not current_token: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No Google token found for this connection" + ) + + # Verify the token + token_verification = await verify_google_token(current_token.tokenAccess) + + if not token_verification.get("valid"): + # Try to refresh the token if verification failed + from modules.security.tokenManager import TokenManager + token_manager = TokenManager() + refreshed_token = token_manager.refresh_token(current_token) + + if refreshed_token: + appInterface.saveConnectionToken(refreshed_token) + # Verify the refreshed token + token_verification = await verify_google_token(refreshed_token.tokenAccess) + + return { + "valid": token_verification.get("valid", False), + "scopes": token_verification.get("scopes", []), + "expires_in": token_verification.get("expires_in", 0), + "email": token_verification.get("email"), + "user_id": token_verification.get("user_id"), + "error": token_verification.get("error") if not token_verification.get("valid") else None + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error verifying Google token: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to verify token: {str(e)}" + ) + @router.post("/refresh") @limiter.limit("10/minute") async def refresh_token( diff --git a/modules/security/tokenManager.py b/modules/security/tokenManager.py index ce34433a..58dabd03 100644 --- a/modules/security/tokenManager.py +++ b/modules/security/tokenManager.py @@ -86,12 +86,16 @@ class TokenManager: def refresh_google_token(self, refresh_token: str, user_id: str, old_token: Token) -> Optional[Token]: """Refresh Google OAuth token using refresh token""" try: + logger.debug(f"refresh_google_token: Starting Google token refresh for user {user_id}") + logger.debug(f"refresh_google_token: Configuration check - client_id: {bool(self.google_client_id)}, client_secret: {bool(self.google_client_secret)}") + if not self.google_client_id or not self.google_client_secret: logger.error("Google OAuth configuration not found") return None # Google token refresh endpoint token_url = "https://oauth2.googleapis.com/token" + logger.debug(f"refresh_google_token: Using token URL: {token_url}") # Prepare refresh request data = { @@ -100,13 +104,22 @@ class TokenManager: "grant_type": "refresh_token", "refresh_token": refresh_token } + logger.debug(f"refresh_google_token: Refresh request data prepared (refresh_token length: {len(refresh_token) if refresh_token else 0})") # Make refresh request with httpx.Client(timeout=30.0) as client: + logger.debug(f"refresh_google_token: Making HTTP request to Google OAuth endpoint") response = client.post(token_url, data=data) + logger.debug(f"refresh_google_token: HTTP response status: {response.status_code}") if response.status_code == 200: token_data = response.json() + logger.debug(f"refresh_google_token: Token refresh successful, creating new token") + + # Validate the response contains required fields + if "access_token" not in token_data: + logger.error("Google token refresh response missing access_token") + return None # Create new token new_token = Token( @@ -114,16 +127,30 @@ class TokenManager: authority=AuthAuthority.GOOGLE, connectionId=old_token.connectionId, # Preserve connection ID tokenAccess=token_data["access_token"], - tokenRefresh=refresh_token, # Google doesn't always provide new refresh token + tokenRefresh=token_data.get("refresh_token", refresh_token), # Use new refresh token if provided tokenType=token_data.get("token_type", "bearer"), expiresAt=create_expiration_timestamp(token_data.get("expires_in", 3600)), createdAt=get_utc_timestamp() ) - + logger.debug(f"refresh_google_token: New token created with ID: {new_token.id}") return new_token else: - logger.error(f"Failed to refresh Google token: {response.status_code} - {response.text}") + error_details = response.text + logger.error(f"Failed to refresh Google token: {response.status_code} - {error_details}") + + # Handle specific error cases + if response.status_code == 400: + try: + error_data = response.json() + error_code = error_data.get("error") + if error_code == "invalid_grant": + logger.warning("Google refresh token is invalid or expired - user needs to re-authenticate") + elif error_code == "invalid_client": + logger.error("Google OAuth client configuration is invalid") + except: + pass + return None except Exception as e: diff --git a/modules/workflow/managerSyncDelta.py b/modules/services/serviceDeltaSync.py similarity index 83% rename from modules/workflow/managerSyncDelta.py rename to modules/services/serviceDeltaSync.py index b66a7488..4e0a0874 100644 --- a/modules/workflow/managerSyncDelta.py +++ b/modules/services/serviceDeltaSync.py @@ -26,21 +26,13 @@ APP_ENV_TYPE = APP_CONFIG.get("APP_ENV_TYPE", "dev") class ManagerSyncDelta: """Manages JIRA to SharePoint synchronization for Delta Group.""" - #SHAREPOINT_SITE_ID = "02830618-4029-4dc8-8d3d-f5168f282249" - #SHAREPOINT_SITE_NAME = "SteeringBPM" - #SHAREPOINT_MAIN_FOLDER = "/sites/SteeringBPM/Freigegebene Dokumente/General/50 Docs hosted by SELISE" - #SHAREPOINT_BACKUP_FOLDER = "/sites/SteeringBPM/Freigegebene Dokumente/General/50 Docs hosted by SELISE/SyncHistory" - #SHAREPOINT_AUDIT_FOLDER = "/sites/SteeringBPM/Freigegebene Dokumente/General/50 Docs hosted by SELISE/SyncHistory" - - # SharePoint site constants using hostname + site path (resolve real site ID at runtime) - SHAREPOINT_HOSTNAME = "pcuster.sharepoint.com" - SHAREPOINT_SITE_PATH = "KM.DELTAG.20968511411" - SHAREPOINT_SITE_NAME = "KM.DELTAG.20968511411" - # Drive-relative (document library) paths, not server-relative "/sites/..." - # Note: Default library name is "Shared Documents" in Graph - SHAREPOINT_MAIN_FOLDER = "1_Arbeitsbereich" - SHAREPOINT_BACKUP_FOLDER = "1_Arbeitsbereich/SyncHistory" - SHAREPOINT_AUDIT_FOLDER = "1_Arbeitsbereich/SyncHistory" + SHAREPOINT_SITE_ID = "02830618-4029-4dc8-8d3d-f5168f282249" + SHAREPOINT_SITE_NAME = "SteeringBPM" + SHAREPOINT_SITE_PATH = "SteeringBPM" + SHAREPOINT_MAIN_FOLDER = "/sites/SteeringBPM/Freigegebene Dokumente/General/50 Docs hosted by SELISE" + SHAREPOINT_BACKUP_FOLDER = "/sites/SteeringBPM/Freigegebene Dokumente/General/50 Docs hosted by SELISE/SyncHistory" + SHAREPOINT_AUDIT_FOLDER = "/sites/SteeringBPM/Freigegebene Dokumente/General/50 Docs hosted by SELISE/SyncHistory" + SHAREPOINT_USER_ID = "patrick.motsch@delta.ch" # Fixed filename for the main CSV file (like original synchronizer) SYNC_FILE_NAME = "DELTAgroup x SELISE Ticket Exchange List.csv" @@ -88,25 +80,25 @@ class ManagerSyncDelta: issue_type=self.JIRA_ISSUE_TYPE ) - # Use the current logged-in user from root interface - activeUser = self.root_interface.currentUser - if not activeUser: - logger.error("No current user available - SharePoint connection required") + # Use the admin user for SharePoint connection + adminUser = self.root_interface.getUserByUsername("admin") + if not adminUser: + logger.error("Admin user not found - SharePoint connection required") return False - logger.info(f"Using current user for SharePoint: {activeUser.id}") + logger.info(f"Using admin user for SharePoint: {adminUser.id}") - # Get SharePoint connection for this user - user_connections = self.root_interface.getUserConnections(activeUser.id) + # Get SharePoint connection for admin user + user_connections = self.root_interface.getUserConnections(adminUser.id) sharepoint_connection = None for connection in user_connections: - if connection.authority == "msft": + if connection.authority == "msft" and connection.externalUsername == self.SHAREPOINT_USER_ID: sharepoint_connection = connection break if not sharepoint_connection: - logger.error("No SharePoint connection found for Delta Group user") + logger.error(f"No SharePoint connection found for user: {self.SHAREPOINT_USER_ID}") return False logger.info(f"Found SharePoint connection: {sharepoint_connection.id}") @@ -187,9 +179,12 @@ class ManagerSyncDelta: ) # Perform the sophisticated sync - logger.info("Performing sophisticated JIRA to CSV sync...") + logger.info("Performing JIRA to CSV sync...") await sync_interface.sync_from_jira_to_csv() - + # TODO: Uncomment when CSV to JIRA sync is implemented + #logger.info("Performing CSV to JIRA sync...") + #await sync_interface.sync_from_csv_to_jira() + logger.info("JIRA to SharePoint synchronization completed successfully") return True @@ -209,7 +204,8 @@ async def perform_sync_jira_delta_group() -> bool: bool: True if synchronization was successful, False otherwise """ try: - if APP_ENV_TYPE != "TASK-ACTIVATE-WHEN-ACCOUNT-READY-prod": + #TODO: ADAPT to prod + if APP_ENV_TYPE != "dev": logger.info("JIRA to SharePoint synchronization: TASK to run only in PROD") return True diff --git a/modules/workflow/managerWorkflow.py b/modules/services/serviceValueonChat.py similarity index 100% rename from modules/workflow/managerWorkflow.py rename to modules/services/serviceValueonChat.py