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.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
|
||||||
from modules.datamodels.datamodelSecurity import Token
|
from modules.datamodels.datamodelSecurity import Token
|
||||||
from modules.auth import getCurrentUser, limiter
|
from modules.auth import getCurrentUser, limiter
|
||||||
|
from modules.auth.oauthConnectTicket import issue_connect_ticket
|
||||||
from modules.auth.tokenRefreshService import token_refresh_service
|
from modules.auth.tokenRefreshService import token_refresh_service
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||||
from modules.interfaces.interfaceDbApp import getInterface
|
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
|
reauth = bool((body or {}).get("reauth")) if isinstance(body, dict) else False
|
||||||
reauthSuffix = "&reauth=1" if reauth else ""
|
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
|
auth_url = None
|
||||||
if connection.authority == AuthAuthority.MSFT:
|
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:
|
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:
|
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:
|
elif connection.authority == AuthAuthority.INFOMANIAK:
|
||||||
# Infomaniak does not use OAuth for data access; the frontend posts a
|
# Infomaniak does not use OAuth for data access; the frontend posts a
|
||||||
# Personal Access Token directly to /api/infomaniak/connections/{id}/token.
|
# 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.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
|
||||||
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
||||||
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
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.timeUtils import createExpirationTimestamp, getUtcTimestamp
|
||||||
from modules.shared.i18nRegistry import apiRouteContext
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
routeApiMsg = apiRouteContext("routeSecurityClickup")
|
routeApiMsg = apiRouteContext("routeSecurityClickup")
|
||||||
|
|
@ -76,28 +77,20 @@ router = APIRouter(
|
||||||
def auth_connect(
|
def auth_connect(
|
||||||
request: Request,
|
request: Request,
|
||||||
connectionId: str = Query(..., description="UserConnection id"),
|
connectionId: str = Query(..., description="UserConnection id"),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
connectTicket: str = Query(..., description="Short-lived ticket from POST /api/connections/{id}/connect"),
|
||||||
) -> RedirectResponse:
|
) -> 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:
|
try:
|
||||||
_require_clickup_config()
|
_require_clickup_config()
|
||||||
interface = getInterface(currentUser)
|
_user, connection = resolve_connect_context(
|
||||||
connections = interface.getUserConnections(currentUser.id)
|
connectTicket, connectionId, _FLOW_CONNECT, AuthAuthority.CLICKUP
|
||||||
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),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
state_jwt = connectTicket
|
||||||
query = urlencode(
|
query = urlencode(
|
||||||
{
|
{
|
||||||
"client_id": CLIENT_ID,
|
"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.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
|
||||||
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
||||||
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
||||||
|
from modules.auth.oauthConnectTicket import resolve_connect_context
|
||||||
from modules.auth import (
|
from modules.auth import (
|
||||||
createAccessToken,
|
createAccessToken,
|
||||||
setAccessTokenCookie,
|
setAccessTokenCookie,
|
||||||
|
|
@ -281,10 +282,13 @@ async def auth_login_callback(
|
||||||
def auth_connect(
|
def auth_connect(
|
||||||
request: Request,
|
request: Request,
|
||||||
connectionId: str = Query(..., description="UserConnection id"),
|
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"),
|
reauth: Optional[int] = Query(0, description="If 1, force the consent screen so newly added scopes are granted"),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
|
||||||
) -> RedirectResponse:
|
) -> 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``
|
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
|
can cause newly added scopes (e.g. calendar.readonly, contacts.readonly) to be
|
||||||
|
|
@ -294,23 +298,11 @@ def auth_connect(
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_require_google_data_config()
|
_require_google_data_config()
|
||||||
interface = getInterface(currentUser)
|
_user, connection = resolve_connect_context(
|
||||||
connections = interface.getUserConnections(currentUser.id)
|
connectTicket, connectionId, _FLOW_CONNECT, AuthAuthority.GOOGLE
|
||||||
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),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
state_jwt = connectTicket
|
||||||
oauth = OAuth2Session(
|
oauth = OAuth2Session(
|
||||||
client_id=DATA_CLIENT_ID,
|
client_id=DATA_CLIENT_ID,
|
||||||
redirect_uri=DATA_REDIRECT_URI,
|
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.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
|
||||||
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
||||||
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
||||||
|
from modules.auth.oauthConnectTicket import resolve_connect_context
|
||||||
from modules.auth import (
|
from modules.auth import (
|
||||||
createAccessToken,
|
createAccessToken,
|
||||||
setAccessTokenCookie,
|
setAccessTokenCookie,
|
||||||
|
|
@ -244,41 +245,30 @@ async def auth_login_callback(
|
||||||
def auth_connect(
|
def auth_connect(
|
||||||
request: Request,
|
request: Request,
|
||||||
connectionId: str = Query(..., description="UserConnection id"),
|
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"),
|
reauth: Optional[int] = Query(0, description="If 1, force the consent screen so newly added scopes are granted"),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
"""Start Microsoft Data OAuth for an existing connection.
|
"""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
|
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,
|
user re-grants permissions and any newly added scopes (e.g. Calendars.Read,
|
||||||
Contacts.Read) actually land on the access token.
|
Contacts.Read) actually land on the access token.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_require_msft_data_config()
|
_require_msft_data_config()
|
||||||
interface = getInterface(currentUser)
|
_user, connection = resolve_connect_context(
|
||||||
connections = interface.getUserConnections(currentUser.id)
|
connectTicket, connectionId, _FLOW_CONNECT, AuthAuthority.MSFT
|
||||||
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")
|
|
||||||
)
|
|
||||||
|
|
||||||
msal_app = msal.ConfidentialClientApplication(
|
msal_app = msal.ConfidentialClientApplication(
|
||||||
DATA_CLIENT_ID,
|
DATA_CLIENT_ID,
|
||||||
authority=AUTHORITY,
|
authority=AUTHORITY,
|
||||||
client_credential=DATA_CLIENT_SECRET,
|
client_credential=DATA_CLIENT_SECRET,
|
||||||
)
|
)
|
||||||
state_jwt = _issue_oauth_state(
|
state_jwt = connectTicket
|
||||||
{
|
|
||||||
"flow": _FLOW_CONNECT,
|
|
||||||
"connectionId": connectionId,
|
|
||||||
"userId": str(currentUser.id),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
login_kwargs: Dict[str, Any] = {"prompt": "select_account", "state": state_jwt}
|
login_kwargs: Dict[str, Any] = {"prompt": "select_account", "state": state_jwt}
|
||||||
login_hint = connection.externalEmail or connection.externalUsername
|
login_hint = connection.externalEmail or connection.externalUsername
|
||||||
if login_hint:
|
if login_hint:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue