error adding connections

This commit is contained in:
Ida 2026-05-21 16:18:59 +02:00
parent 13e8d0a808
commit ee37d36a42
5 changed files with 152 additions and 59 deletions

View file

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

View file

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

View file

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

View file

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

View file

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