feat(billing): Nutzerhinweise bei leerem Budget + Mandats-Mail (402/SSE) Gateway - InsufficientBalanceException: billingModel, userAction (TOP_UP_SELF / CONTACT_MANDATE_ADMIN), DE/EN-Texte, toClientDict(), fromBalanceCheck() - HTTP 402 + JSON detail für globale API-Fehlerbehandlung - AI/Chatbot: vor Raise ggf. E-Mail an BillingSettings.notifyEmails (PREPAY_MANDATE, Throttle 1h/Mandat) via billingExhaustedNotify - Agent-Loop & Workspace-Route: SSE-ERROR mit strukturiertem Billing-Payload - datamodelBilling: notifyEmails-Doku für Pool-Alerts frontend_nyla - useWorkspace: SSE onError für INSUFFICIENT_BALANCE mit messageDe/En und Hinweis auf Billing-Pfad bei TOP_UP_SELF
725 lines
26 KiB
Python
725 lines
26 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Routes for Google authentication — split Auth app vs Data app.
|
|
|
|
See wiki: concepts/OAuth-Auth-vs-Data-Connection-Konzept.md
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, Response, status, Depends, Query
|
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
|
import logging
|
|
import json
|
|
import time
|
|
from typing import Dict, Any, Optional
|
|
from requests_oauthlib import OAuth2Session
|
|
import httpx
|
|
from jose import jwt as jose_jwt
|
|
from jose import JWTError
|
|
|
|
from modules.shared.configuration import APP_CONFIG
|
|
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
|
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
|
|
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
|
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
|
from modules.auth import (
|
|
createAccessToken,
|
|
setAccessTokenCookie,
|
|
createRefreshToken,
|
|
setRefreshTokenCookie,
|
|
clearAccessTokenCookie,
|
|
clearRefreshTokenCookie,
|
|
)
|
|
from modules.auth.tokenManager import TokenManager
|
|
from modules.auth.oauthProviderConfig import googleAuthScopes, googleDataScopes
|
|
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_FLOW_LOGIN = "google_login"
|
|
_FLOW_CONNECT = "google_connect"
|
|
|
|
|
|
async def verify_google_token(access_token: str) -> Dict[str, Any]:
|
|
"""Verify Google access token and return token info including scopes."""
|
|
try:
|
|
headers = {
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
async with httpx.AsyncClient() as client:
|
|
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()
|
|
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"),
|
|
}
|
|
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)}
|
|
|
|
|
|
def _issue_oauth_state(claims: Dict[str, Any]) -> str:
|
|
body = {**claims, "exp": int(time.time()) + 600}
|
|
return jose_jwt.encode(body, SECRET_KEY, algorithm=ALGORITHM)
|
|
|
|
|
|
def _parse_oauth_state(state: str) -> Dict[str, Any]:
|
|
try:
|
|
return jose_jwt.decode(state, SECRET_KEY, algorithms=[ALGORITHM])
|
|
except JWTError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid OAuth state: {e}"
|
|
) from e
|
|
|
|
|
|
router = APIRouter(
|
|
prefix="/api/google",
|
|
tags=["Security Google"],
|
|
responses={
|
|
404: {"description": "Not found"},
|
|
400: {"description": "Bad request"},
|
|
401: {"description": "Unauthorized"},
|
|
403: {"description": "Forbidden"},
|
|
500: {"description": "Internal server error"},
|
|
},
|
|
)
|
|
|
|
AUTH_CLIENT_ID = APP_CONFIG.get("Service_GOOGLE_AUTH_CLIENT_ID")
|
|
AUTH_CLIENT_SECRET = APP_CONFIG.get("Service_GOOGLE_AUTH_CLIENT_SECRET")
|
|
AUTH_REDIRECT_URI = APP_CONFIG.get("Service_GOOGLE_AUTH_REDIRECT_URI")
|
|
DATA_CLIENT_ID = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_ID")
|
|
DATA_CLIENT_SECRET = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_SECRET")
|
|
DATA_REDIRECT_URI = APP_CONFIG.get("Service_GOOGLE_DATA_REDIRECT_URI")
|
|
|
|
|
|
@router.get("/config")
|
|
def get_config():
|
|
"""Debug: OAuth configuration (Auth vs Data apps)."""
|
|
return {
|
|
"auth_client_id": AUTH_CLIENT_ID,
|
|
"auth_client_secret": "***" if AUTH_CLIENT_SECRET else None,
|
|
"auth_redirect_uri": AUTH_REDIRECT_URI,
|
|
"auth_scopes": googleAuthScopes,
|
|
"data_client_id": DATA_CLIENT_ID,
|
|
"data_client_secret": "***" if DATA_CLIENT_SECRET else None,
|
|
"data_redirect_uri": DATA_REDIRECT_URI,
|
|
"data_scopes": googleDataScopes,
|
|
"config_loaded": bool(
|
|
AUTH_CLIENT_ID and AUTH_CLIENT_SECRET and AUTH_REDIRECT_URI and DATA_CLIENT_ID and DATA_CLIENT_SECRET and DATA_REDIRECT_URI
|
|
),
|
|
}
|
|
|
|
|
|
def _require_google_auth_config():
|
|
if not AUTH_CLIENT_ID or not AUTH_CLIENT_SECRET or not AUTH_REDIRECT_URI:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Google Auth OAuth is not configured (Service_GOOGLE_AUTH_*)",
|
|
)
|
|
|
|
|
|
def _require_google_data_config():
|
|
if not DATA_CLIENT_ID or not DATA_CLIENT_SECRET or not DATA_REDIRECT_URI:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Google Data OAuth is not configured (Service_GOOGLE_DATA_*)",
|
|
)
|
|
|
|
|
|
@router.get("/auth/login")
|
|
@limiter.limit("5/minute")
|
|
def auth_login(request: Request) -> RedirectResponse:
|
|
"""Start Google login (Auth app — minimal scopes)."""
|
|
try:
|
|
_require_google_auth_config()
|
|
state_jwt = _issue_oauth_state({"flow": _FLOW_LOGIN})
|
|
oauth = OAuth2Session(
|
|
client_id=AUTH_CLIENT_ID,
|
|
redirect_uri=AUTH_REDIRECT_URI,
|
|
scope=googleAuthScopes,
|
|
)
|
|
auth_url, _ = oauth.authorization_url(
|
|
"https://accounts.google.com/o/oauth2/auth",
|
|
state=state_jwt,
|
|
access_type="online",
|
|
prompt="consent select_account",
|
|
)
|
|
return RedirectResponse(auth_url)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error initiating Google auth login: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to initiate Google login: {str(e)}",
|
|
)
|
|
|
|
|
|
@router.get("/auth/login/callback")
|
|
async def auth_login_callback(
|
|
code: str, state: str, request: Request, response: Response
|
|
) -> HTMLResponse:
|
|
"""OAuth callback for Google Auth app (login only)."""
|
|
state_data = _parse_oauth_state(state)
|
|
if state_data.get("flow") != _FLOW_LOGIN:
|
|
raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback")
|
|
|
|
_require_google_auth_config()
|
|
oauth = OAuth2Session(client_id=AUTH_CLIENT_ID, redirect_uri=AUTH_REDIRECT_URI)
|
|
token_data = oauth.fetch_token(
|
|
"https://oauth2.googleapis.com/token",
|
|
client_secret=AUTH_CLIENT_SECRET,
|
|
code=code,
|
|
include_client_id=True,
|
|
)
|
|
access_token = token_data.get("access_token")
|
|
if not access_token:
|
|
return HTMLResponse(
|
|
content="<html><body><h1>Authentication Failed</h1><p>No access token.</p></body></html>",
|
|
status_code=400,
|
|
)
|
|
|
|
token_verification = await verify_google_token(access_token)
|
|
if not token_verification.get("valid"):
|
|
return HTMLResponse(
|
|
content=f"<html><body><h1>Authentication Failed</h1><p>{token_verification.get('error')}</p></body></html>",
|
|
status_code=400,
|
|
)
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
async with httpx.AsyncClient() as client:
|
|
user_info_response = await client.get(
|
|
"https://www.googleapis.com/oauth2/v2/userinfo", headers=headers
|
|
)
|
|
if user_info_response.status_code != 200:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to get user info from Google",
|
|
)
|
|
user_info = user_info_response.json()
|
|
|
|
rootInterface = getRootInterface()
|
|
user = rootInterface.getUserByUsername(user_info.get("email"))
|
|
if not user:
|
|
user = rootInterface.createUser(
|
|
username=user_info.get("email"),
|
|
email=user_info.get("email"),
|
|
fullName=user_info.get("name"),
|
|
authenticationAuthority=AuthAuthority.GOOGLE,
|
|
externalId=user_info.get("id"),
|
|
externalUsername=user_info.get("email"),
|
|
externalEmail=user_info.get("email"),
|
|
addExternalIdentityConnection=False,
|
|
)
|
|
|
|
jwt_token_data = {
|
|
"sub": user.username,
|
|
"userId": str(user.id),
|
|
"authenticationAuthority": AuthAuthority.GOOGLE.value,
|
|
}
|
|
jwt_token, jwt_expires_at = createAccessToken(jwt_token_data)
|
|
refresh_token, _refresh_expires = createRefreshToken(jwt_token_data)
|
|
from jose import jwt
|
|
|
|
payload = jwt.decode(jwt_token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
jti = payload.get("jti")
|
|
|
|
token = Token(
|
|
id=jti,
|
|
userId=user.id,
|
|
authority=AuthAuthority.GOOGLE,
|
|
tokenPurpose=TokenPurpose.AUTH_SESSION,
|
|
tokenAccess=jwt_token,
|
|
tokenRefresh="",
|
|
tokenType="bearer",
|
|
expiresAt=jwt_expires_at.timestamp(),
|
|
createdAt=getUtcTimestamp(),
|
|
)
|
|
appInterface = getInterface(user)
|
|
appInterface.saveAccessToken(token)
|
|
token_dict = token.model_dump()
|
|
|
|
html_response = HTMLResponse(
|
|
content=f"""
|
|
<html>
|
|
<head><title>Authentication Successful</title></head>
|
|
<body>
|
|
<script>
|
|
if (window.opener) {{
|
|
window.opener.postMessage({{
|
|
type: 'google_auth_success',
|
|
token_data: {json.dumps(token_dict)}
|
|
}}, '*');
|
|
}}
|
|
setTimeout(() => window.close(), 1000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
)
|
|
setAccessTokenCookie(html_response, jwt_token, expiresDelta=None)
|
|
setRefreshTokenCookie(html_response, refresh_token)
|
|
return html_response
|
|
|
|
|
|
@router.get("/auth/connect")
|
|
@limiter.limit("5/minute")
|
|
def auth_connect(
|
|
request: Request,
|
|
connectionId: str = Query(..., description="UserConnection id"),
|
|
currentUser: User = Depends(getCurrentUser),
|
|
) -> RedirectResponse:
|
|
"""Start Google Data OAuth for an existing connection (requires gateway session)."""
|
|
try:
|
|
_require_google_data_config()
|
|
interface = getInterface(currentUser)
|
|
connections = interface.getUserConnections(currentUser.id)
|
|
connection = None
|
|
for conn in connections:
|
|
if conn.id == connectionId and conn.authority == AuthAuthority.GOOGLE:
|
|
connection = conn
|
|
break
|
|
if not connection:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Google connection not found")
|
|
|
|
state_jwt = _issue_oauth_state(
|
|
{
|
|
"flow": _FLOW_CONNECT,
|
|
"connectionId": connectionId,
|
|
"userId": str(currentUser.id),
|
|
}
|
|
)
|
|
oauth = OAuth2Session(
|
|
client_id=DATA_CLIENT_ID,
|
|
redirect_uri=DATA_REDIRECT_URI,
|
|
scope=googleDataScopes,
|
|
)
|
|
extra_params: Dict[str, Any] = {
|
|
"access_type": "offline",
|
|
"include_granted_scopes": "true",
|
|
"state": state_jwt,
|
|
}
|
|
login_hint = connection.externalEmail or connection.externalUsername
|
|
if login_hint:
|
|
extra_params["login_hint"] = login_hint
|
|
if "@" in login_hint:
|
|
extra_params["hd"] = login_hint.split("@", 1)[1]
|
|
extra_params["prompt"] = "consent"
|
|
else:
|
|
extra_params["prompt"] = "consent select_account"
|
|
|
|
auth_url, _ = oauth.authorization_url(
|
|
"https://accounts.google.com/o/oauth2/auth", **extra_params
|
|
)
|
|
return RedirectResponse(auth_url)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error initiating Google connect: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to initiate Google connect: {str(e)}",
|
|
)
|
|
|
|
|
|
@router.get("/auth/connect/callback")
|
|
async def auth_connect_callback(
|
|
code: str, state: str, request: Request, response: Response
|
|
) -> HTMLResponse:
|
|
"""OAuth callback for Google Data app (UserConnection)."""
|
|
state_data = _parse_oauth_state(state)
|
|
if state_data.get("flow") != _FLOW_CONNECT:
|
|
raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback")
|
|
connection_id = state_data.get("connectionId")
|
|
user_id = state_data.get("userId")
|
|
if not connection_id or not user_id:
|
|
raise HTTPException(status_code=400, detail="Missing connection or user in OAuth state")
|
|
|
|
_require_google_data_config()
|
|
oauth = OAuth2Session(client_id=DATA_CLIENT_ID, redirect_uri=DATA_REDIRECT_URI)
|
|
token_data = oauth.fetch_token(
|
|
"https://oauth2.googleapis.com/token",
|
|
client_secret=DATA_CLIENT_SECRET,
|
|
code=code,
|
|
include_client_id=True,
|
|
)
|
|
granted_scopes = token_data.get("scope", "")
|
|
token_response = {
|
|
"access_token": token_data.get("access_token"),
|
|
"refresh_token": token_data.get("refresh_token", ""),
|
|
"token_type": token_data.get("token_type", "bearer"),
|
|
"expires_in": token_data.get("expires_in", 0),
|
|
}
|
|
|
|
if not token_response.get("refresh_token"):
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
existing_tokens = rootInterface.getTokensByConnectionIdAndAuthority(
|
|
connection_id, AuthAuthority.GOOGLE
|
|
)
|
|
if existing_tokens:
|
|
existing_tokens.sort(
|
|
key=lambda x: parseTimestamp(x.createdAt, default=0), reverse=True
|
|
)
|
|
token_response["refresh_token"] = existing_tokens[0].tokenRefresh or ""
|
|
except Exception:
|
|
pass
|
|
|
|
if not token_response.get("access_token"):
|
|
return HTMLResponse(
|
|
content="<html><body><h1>Connection Failed</h1><p>No access token.</p></body></html>",
|
|
status_code=400,
|
|
)
|
|
|
|
token_verification = await verify_google_token(token_response["access_token"])
|
|
if not token_verification.get("valid"):
|
|
return HTMLResponse(
|
|
content=f"<html><body><h1>Connection Failed</h1><p>{token_verification.get('error')}</p></body></html>",
|
|
status_code=400,
|
|
)
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {token_response['access_token']}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
async with httpx.AsyncClient() as client:
|
|
user_info_response = await client.get(
|
|
"https://www.googleapis.com/oauth2/v2/userinfo", headers=headers
|
|
)
|
|
if user_info_response.status_code != 200:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to get user info from Google",
|
|
)
|
|
user_info = user_info_response.json()
|
|
|
|
rootInterface = getRootInterface()
|
|
user = rootInterface.getUser(user_id)
|
|
if not user:
|
|
return HTMLResponse(
|
|
content=f"""
|
|
<html><body><script>
|
|
if (window.opener) {{
|
|
window.opener.postMessage({{ type: 'google_connection_error', error: 'User not found' }}, '*');
|
|
setTimeout(() => window.close(), 1000);
|
|
}} else window.close();
|
|
</script></body></html>
|
|
""",
|
|
status_code=404,
|
|
)
|
|
|
|
interface = getInterface(user)
|
|
connections = interface.getUserConnections(user_id)
|
|
connection = None
|
|
for conn in connections:
|
|
if conn.id == connection_id:
|
|
connection = conn
|
|
break
|
|
if not connection:
|
|
return HTMLResponse(
|
|
content=f"""
|
|
<html><body><script>
|
|
if (window.opener) {{
|
|
window.opener.postMessage({{ type: 'google_connection_error', error: 'Connection not found' }}, '*');
|
|
setTimeout(() => window.close(), 1000);
|
|
}} else window.close();
|
|
</script></body></html>
|
|
""",
|
|
status_code=404,
|
|
)
|
|
|
|
try:
|
|
connection.status = ConnectionStatus.ACTIVE
|
|
connection.lastChecked = getUtcTimestamp()
|
|
connection.expiresAt = getUtcTimestamp() + token_response.get("expires_in", 0)
|
|
connection.externalId = user_info.get("id")
|
|
connection.externalUsername = user_info.get("email")
|
|
connection.externalEmail = user_info.get("email")
|
|
granted_scopes_list = (
|
|
granted_scopes
|
|
if isinstance(granted_scopes, list)
|
|
else (granted_scopes.split(" ") if granted_scopes else googleDataScopes)
|
|
)
|
|
connection.grantedScopes = granted_scopes_list
|
|
rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump())
|
|
|
|
token = Token(
|
|
userId=user.id,
|
|
authority=AuthAuthority.GOOGLE,
|
|
connectionId=connection_id,
|
|
tokenPurpose=TokenPurpose.DATA_CONNECTION,
|
|
tokenAccess=token_response["access_token"],
|
|
tokenRefresh=token_response.get("refresh_token", ""),
|
|
tokenType=token_response.get("token_type", "bearer"),
|
|
expiresAt=createExpirationTimestamp(token_response.get("expires_in", 0)),
|
|
createdAt=getUtcTimestamp(),
|
|
)
|
|
interface.saveConnectionToken(token)
|
|
|
|
return HTMLResponse(
|
|
content=f"""
|
|
<html>
|
|
<head><title>Connection Successful</title></head>
|
|
<body>
|
|
<script>
|
|
if (window.opener) {{
|
|
window.opener.postMessage({{
|
|
type: 'google_connection_success',
|
|
connection: {{
|
|
id: '{connection.id}',
|
|
status: 'connected',
|
|
type: 'google',
|
|
lastChecked: {getUtcTimestamp()},
|
|
expiresAt: {createExpirationTimestamp(token_response.get("expires_in", 0))}
|
|
}}
|
|
}}, '*');
|
|
setTimeout(() => window.close(), 1000);
|
|
}} else {{
|
|
window.close();
|
|
}}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error updating Google connection: {str(e)}", exc_info=True)
|
|
return HTMLResponse(
|
|
content=f"""
|
|
<html><body><script>
|
|
if (window.opener) {{
|
|
window.opener.postMessage({{ type: 'google_connection_error', error: {json.dumps(str(e))} }}, '*');
|
|
setTimeout(() => window.close(), 1000);
|
|
}} else window.close();
|
|
</script></body></html>
|
|
""",
|
|
status_code=500,
|
|
)
|
|
|
|
|
|
@router.get("/me", response_model=User)
|
|
@limiter.limit("30/minute")
|
|
def get_current_user(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser),
|
|
) -> User:
|
|
return currentUser
|
|
|
|
|
|
@router.post("/logout")
|
|
@limiter.limit("10/minute")
|
|
def logout(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser),
|
|
) -> JSONResponse:
|
|
"""
|
|
End only the PowerOn gateway session. Does not revoke the Google account session in the browser.
|
|
"""
|
|
try:
|
|
appInterface = getInterface(currentUser)
|
|
|
|
token = request.cookies.get("auth_token")
|
|
if not token:
|
|
auth_header = request.headers.get("Authorization")
|
|
if auth_header and auth_header.lower().startswith("bearer "):
|
|
token = auth_header.split(" ", 1)[1].strip()
|
|
|
|
if not token:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="No token found",
|
|
)
|
|
|
|
try:
|
|
payload = jose_jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
session_id = payload.get("sid") or payload.get("sessionId")
|
|
jti = payload.get("jti")
|
|
except Exception as e:
|
|
logger.error(f"Failed to decode JWT on Google logout: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid token",
|
|
)
|
|
|
|
revoked = 0
|
|
if session_id:
|
|
revoked = appInterface.revokeTokensBySessionId(
|
|
session_id,
|
|
currentUser.id,
|
|
AuthAuthority.GOOGLE,
|
|
revokedBy=currentUser.id,
|
|
reason="logout",
|
|
)
|
|
elif jti:
|
|
appInterface.revokeTokenById(jti, revokedBy=currentUser.id, reason="logout")
|
|
revoked = 1
|
|
|
|
try:
|
|
from modules.shared.auditLogger import audit_logger
|
|
|
|
audit_logger.logUserAccess(
|
|
userId=str(currentUser.id),
|
|
mandateId="system",
|
|
action="logout",
|
|
successInfo=f"google_gateway_logout revoked={revoked}",
|
|
ipAddress=request.client.host if request.client else None,
|
|
userAgent=request.headers.get("user-agent"),
|
|
success=True,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
json_response = JSONResponse(
|
|
{
|
|
"message": "Successfully logged out from application (Google account stays signed in elsewhere)",
|
|
"revokedTokens": revoked,
|
|
}
|
|
)
|
|
clearAccessTokenCookie(json_response)
|
|
clearRefreshTokenCookie(json_response)
|
|
return json_response
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error during logout: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
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]:
|
|
try:
|
|
appInterface = getInterface(currentUser)
|
|
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",
|
|
)
|
|
current_token = TokenManager().getFreshToken(google_connection.id)
|
|
if not current_token:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="No Google token found for this connection",
|
|
)
|
|
token_verification = await verify_google_token(current_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(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser),
|
|
) -> Dict[str, Any]:
|
|
try:
|
|
appInterface = getInterface(currentUser)
|
|
payload = {}
|
|
try:
|
|
payload = await request.json()
|
|
except Exception:
|
|
payload = {}
|
|
requested_connection_id = (
|
|
payload.get("connectionId") if isinstance(payload, dict) else None
|
|
)
|
|
connections = appInterface.getUserConnections(currentUser.id)
|
|
google_connection = None
|
|
if requested_connection_id:
|
|
for conn in connections:
|
|
if conn.id == requested_connection_id and conn.authority == AuthAuthority.GOOGLE:
|
|
google_connection = conn
|
|
break
|
|
if not google_connection:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Requested Google connection not found for current user",
|
|
)
|
|
else:
|
|
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",
|
|
)
|
|
current_token = TokenManager().getFreshToken(google_connection.id)
|
|
if not current_token:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="No Google token found for this connection",
|
|
)
|
|
expiresAtValue = parseTimestamp(current_token.expiresAt)
|
|
google_connection.expiresAt = (
|
|
expiresAtValue if expiresAtValue else google_connection.expiresAt
|
|
)
|
|
google_connection.lastChecked = getUtcTimestamp()
|
|
google_connection.status = ConnectionStatus.ACTIVE
|
|
appInterface.db.recordModify(
|
|
UserConnection, google_connection.id, google_connection.model_dump()
|
|
)
|
|
currentTime = getUtcTimestamp()
|
|
expiresAt = parseTimestamp(current_token.expiresAt)
|
|
expiresIn = int(expiresAt - currentTime) if expiresAt else 0
|
|
return {
|
|
"message": "Token refreshed successfully",
|
|
"expires_at": expiresAt,
|
|
"expires_in_seconds": expiresIn,
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error refreshing Google token: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to refresh token: {str(e)}",
|
|
)
|