From ee37d36a4208f496051b8df5a9bc8dc949b04cce Mon Sep 17 00:00:00 2001 From: Ida Date: Thu, 21 May 2026 16:18:59 +0200 Subject: [PATCH] error adding connections --- modules/auth/oauthConnectTicket.py | 101 +++++++++++++++++++++++++ modules/routes/routeDataConnections.py | 25 +++++- modules/routes/routeSecurityClickup.py | 29 +++---- modules/routes/routeSecurityGoogle.py | 28 +++---- modules/routes/routeSecurityMsft.py | 28 +++---- 5 files changed, 152 insertions(+), 59 deletions(-) create mode 100644 modules/auth/oauthConnectTicket.py diff --git a/modules/auth/oauthConnectTicket.py b/modules/auth/oauthConnectTicket.py new file mode 100644 index 00000000..f54187cb --- /dev/null +++ b/modules/auth/oauthConnectTicket.py @@ -0,0 +1,101 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Short-lived signed tickets for OAuth data-connection popups. + +The UI authenticates API calls with a Bearer token in localStorage, but +``window.open(authUrl)`` cannot send that header. Cross-origin httpOnly cookies +are unreliable in int/prod (UI on poweron-center.net, API on poweron.swiss). +Login popups work without a session because ``/auth/login`` is public; connect +popups hit ``/auth/connect``, which used to require ``getCurrentUser``. + +Flow: POST ``/api/connections/{id}/connect`` (Bearer-authenticated) issues a +ticket; the popup opens ``/auth/connect?connectTicket=...`` which validates the +ticket instead of cookies. +""" + +import time +from typing import Any, Dict, Tuple + +from fastapi import HTTPException, status +from jose import JWTError, jwt as jose_jwt + +from modules.auth.jwtService import ALGORITHM, SECRET_KEY +from modules.datamodels.datamodelUam import AuthAuthority, User, UserConnection +from modules.interfaces.interfaceDbApp import getInterface, getRootInterface +from modules.shared.i18nRegistry import apiRouteContext + +_msg = apiRouteContext("oauthConnectTicket") + +_CONNECT_TICKET_TTL_SEC = 600 + + +def issue_connect_ticket(flow: str, connection_id: str, user_id: str) -> str: + """Issue a short-lived JWT for starting a data-connection OAuth popup.""" + body = { + "flow": flow, + "connectionId": connection_id, + "userId": str(user_id), + "exp": int(time.time()) + _CONNECT_TICKET_TTL_SEC, + } + return jose_jwt.encode(body, SECRET_KEY, algorithm=ALGORITHM) + + +def parse_connect_ticket(ticket: str, expected_flow: str) -> Dict[str, Any]: + """Validate connect ticket signature, expiry, and flow.""" + try: + data = jose_jwt.decode(ticket, SECRET_KEY, algorithms=[ALGORITHM]) + except JWTError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=_msg("Invalid or expired connect ticket"), + ) from e + if data.get("flow") != expected_flow: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=_msg("Invalid connect ticket flow"), + ) + connection_id = data.get("connectionId") + user_id = data.get("userId") + if not connection_id or not user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=_msg("Incomplete connect ticket"), + ) + return data + + +def resolve_connect_context( + connect_ticket: str, + connection_id: str, + expected_flow: str, + authority: AuthAuthority, +) -> Tuple[User, UserConnection]: + """Validate ticket and return the user + connection for OAuth redirect.""" + state = parse_connect_ticket(connect_ticket, expected_flow) + if state.get("connectionId") != connection_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=_msg("Connection ID does not match connect ticket"), + ) + + root = getRootInterface() + user = root.getUser(state["userId"]) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=_msg("User not found"), + ) + + interface = getInterface(user) + connection = None + for conn in interface.getUserConnections(user.id): + if conn.id == connection_id and conn.authority == authority: + connection = conn + break + if not connection: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=_msg("Connection not found"), + ) + return user, connection diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 2bc48042..7ab0f6d7 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -22,6 +22,7 @@ from fastapi.responses import JSONResponse from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus from modules.datamodels.datamodelSecurity import Token from modules.auth import getCurrentUser, limiter +from modules.auth.oauthConnectTicket import issue_connect_ticket from modules.auth.tokenRefreshService import token_refresh_service from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.interfaces.interfaceDbApp import getInterface @@ -564,14 +565,30 @@ def connect_service( reauth = bool((body or {}).get("reauth")) if isinstance(body, dict) else False reauthSuffix = "&reauth=1" if reauth else "" - # Data-app OAuth (JWT state issued server-side in /auth/connect) + # Data-app OAuth: issue connect ticket here (Bearer auth) so the popup + # does not depend on httpOnly cookies (UI uses localStorage Bearer). auth_url = None if connection.authority == AuthAuthority.MSFT: - auth_url = f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}{reauthSuffix}" + ticket = issue_connect_ticket("msft_connect", connectionId, str(currentUser.id)) + ticket_param = f"&connectTicket={quote(ticket, safe='')}" + auth_url = ( + f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}" + f"{ticket_param}{reauthSuffix}" + ) elif connection.authority == AuthAuthority.GOOGLE: - auth_url = f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}{reauthSuffix}" + ticket = issue_connect_ticket("google_connect", connectionId, str(currentUser.id)) + ticket_param = f"&connectTicket={quote(ticket, safe='')}" + auth_url = ( + f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}" + f"{ticket_param}{reauthSuffix}" + ) elif connection.authority == AuthAuthority.CLICKUP: - auth_url = f"/api/clickup/auth/connect?connectionId={quote(connectionId, safe='')}{reauthSuffix}" + ticket = issue_connect_ticket("clickup_connect", connectionId, str(currentUser.id)) + ticket_param = f"&connectTicket={quote(ticket, safe='')}" + auth_url = ( + f"/api/clickup/auth/connect?connectionId={quote(connectionId, safe='')}" + f"{ticket_param}{reauthSuffix}" + ) elif connection.authority == AuthAuthority.INFOMANIAK: # Infomaniak does not use OAuth for data access; the frontend posts a # Personal Access Token directly to /api/infomaniak/connections/{id}/token. diff --git a/modules/routes/routeSecurityClickup.py b/modules/routes/routeSecurityClickup.py index d6f71d20..935509bc 100644 --- a/modules/routes/routeSecurityClickup.py +++ b/modules/routes/routeSecurityClickup.py @@ -18,6 +18,7 @@ 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.oauthConnectTicket import resolve_connect_context from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeSecurityClickup") @@ -76,28 +77,20 @@ router = APIRouter( def auth_connect( request: Request, connectionId: str = Query(..., description="UserConnection id"), - currentUser: User = Depends(getCurrentUser), + connectTicket: str = Query(..., description="Short-lived ticket from POST /api/connections/{id}/connect"), ) -> RedirectResponse: - """Start ClickUp OAuth for an existing connection (requires gateway session).""" + """Start ClickUp OAuth for an existing connection. + + Authenticated via ``connectTicket`` (issued by POST connect) so the popup + works when the UI uses Bearer tokens in localStorage instead of cookies. + """ try: _require_clickup_config() - interface = getInterface(currentUser) - connections = interface.getUserConnections(currentUser.id) - connection = None - for conn in connections: - if conn.id == connectionId and conn.authority == AuthAuthority.CLICKUP: - connection = conn - break - if not connection: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("ClickUp connection not found")) - - state_jwt = _issue_oauth_state( - { - "flow": _FLOW_CONNECT, - "connectionId": connectionId, - "userId": str(currentUser.id), - } + _user, connection = resolve_connect_context( + connectTicket, connectionId, _FLOW_CONNECT, AuthAuthority.CLICKUP ) + + state_jwt = connectTicket query = urlencode( { "client_id": CLIENT_ID, diff --git a/modules/routes/routeSecurityGoogle.py b/modules/routes/routeSecurityGoogle.py index 7b6c1c64..87df681a 100644 --- a/modules/routes/routeSecurityGoogle.py +++ b/modules/routes/routeSecurityGoogle.py @@ -22,6 +22,7 @@ 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.oauthConnectTicket import resolve_connect_context from modules.auth import ( createAccessToken, setAccessTokenCookie, @@ -281,10 +282,13 @@ async def auth_login_callback( def auth_connect( request: Request, connectionId: str = Query(..., description="UserConnection id"), + connectTicket: str = Query(..., description="Short-lived ticket from POST /api/connections/{id}/connect"), reauth: Optional[int] = Query(0, description="If 1, force the consent screen so newly added scopes are granted"), - currentUser: User = Depends(getCurrentUser), ) -> RedirectResponse: - """Start Google Data OAuth for an existing connection (requires gateway session). + """Start Google Data OAuth for an existing connection. + + Authenticated via ``connectTicket`` (issued by POST connect) so the popup + works when the UI uses Bearer tokens in localStorage instead of cookies. Google already defaults to ``prompt=consent`` here, but ``include_granted_scopes=true`` can cause newly added scopes (e.g. calendar.readonly, contacts.readonly) to be @@ -294,23 +298,11 @@ def auth_connect( """ 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=routeApiMsg("Google connection not found")) - - state_jwt = _issue_oauth_state( - { - "flow": _FLOW_CONNECT, - "connectionId": connectionId, - "userId": str(currentUser.id), - } + _user, connection = resolve_connect_context( + connectTicket, connectionId, _FLOW_CONNECT, AuthAuthority.GOOGLE ) + + state_jwt = connectTicket oauth = OAuth2Session( client_id=DATA_CLIENT_ID, redirect_uri=DATA_REDIRECT_URI, diff --git a/modules/routes/routeSecurityMsft.py b/modules/routes/routeSecurityMsft.py index a2768a2b..67a598dd 100644 --- a/modules/routes/routeSecurityMsft.py +++ b/modules/routes/routeSecurityMsft.py @@ -23,6 +23,7 @@ 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.oauthConnectTicket import resolve_connect_context from modules.auth import ( createAccessToken, setAccessTokenCookie, @@ -244,41 +245,30 @@ async def auth_login_callback( def auth_connect( request: Request, connectionId: str = Query(..., description="UserConnection id"), + connectTicket: str = Query(..., description="Short-lived ticket from POST /api/connections/{id}/connect"), reauth: Optional[int] = Query(0, description="If 1, force the consent screen so newly added scopes are granted"), - currentUser: User = Depends(getCurrentUser), ) -> RedirectResponse: """Start Microsoft Data OAuth for an existing connection. + Authenticated via ``connectTicket`` (issued by POST connect) so the popup + works when the UI uses Bearer tokens in localStorage instead of cookies. + With ``reauth=1`` the consent screen is forced (``prompt=consent``) so the user re-grants permissions and any newly added scopes (e.g. Calendars.Read, Contacts.Read) actually land on the access token. """ try: _require_msft_data_config() - interface = getInterface(currentUser) - connections = interface.getUserConnections(currentUser.id) - connection = None - for conn in connections: - if conn.id == connectionId and conn.authority == AuthAuthority.MSFT: - connection = conn - break - if not connection: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("Microsoft connection not found") - ) + _user, connection = resolve_connect_context( + connectTicket, connectionId, _FLOW_CONNECT, AuthAuthority.MSFT + ) msal_app = msal.ConfidentialClientApplication( DATA_CLIENT_ID, authority=AUTHORITY, client_credential=DATA_CLIENT_SECRET, ) - state_jwt = _issue_oauth_state( - { - "flow": _FLOW_CONNECT, - "connectionId": connectionId, - "userId": str(currentUser.id), - } - ) + state_jwt = connectTicket login_kwargs: Dict[str, Any] = {"prompt": "select_account", "state": state_jwt} login_hint = connection.externalEmail or connection.externalUsername if login_hint: