google oauth2 fixes

This commit is contained in:
ValueOn AG 2025-09-10 17:47:17 +02:00
parent 8cfe6fe386
commit 955a733063
6 changed files with 195 additions and 34 deletions

2
app.py
View file

@ -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,

View file

@ -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

View file

@ -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(

View file

@ -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:

View file

@ -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