error adding connections
This commit is contained in:
parent
13e8d0a808
commit
ee37d36a42
5 changed files with 152 additions and 59 deletions
101
modules/auth/oauthConnectTicket.py
Normal file
101
modules/auth/oauthConnectTicket.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue