commit
7e2fe5bb48
6 changed files with 195 additions and 34 deletions
2
app.py
2
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"<html><body><h1>Authentication Failed</h1><p>Token verification failed: {token_verification.get('error')}</p></body></html>",
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
Loading…
Reference in a new issue