feat: connecteurs Google + Microsoft 365 (OAuth2 PKCE) — phase 6/8 (v7.56.0)
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / test (push) Failing after 16m4s
FlowDeck CI / docker (push) Skipped

- app/services/oauth_connectors.py : flow OAuth2 complet PKCE (S256) pour
  2 fournisseurs décrits par 1 dict — Google (Drive/Gmail/Calendar en lecture
  seule) et Microsoft 365 (Graph Files.Read / Mail.Read / Calendars.Read) ;
  begin() = URL d'autorisation + state + code_verifier, complete() = échange du
  code, access_token() = refresh automatique (60 s de marge, refresh_token
  conservé si absent de la réponse), api_get() = path absolu refusé + validation
  SSRF + borne 20 000 car.
- Tokens chiffrés Fernet en réutilisant calendar_sync._encrypt_tokens (zéro
  dépendance) dans la table connector_tokens (migration 33, PK (kind, user_id)).
- 4 routes /api/agent/connectors/oauth/{kind}/… : status, authorize (cookies
  d'état HttpOnly 10 min, retour same-origin validé), callback (GET safe, state
  comparé en temps constant, tokens stockés puis cookies purgés, redirection
  ?oauth=connected / ?oauth_error=), disconnect. OpenAPI 523 chemins.
- Config + .env.example : GOOGLE_CLIENT_ID/SECRET, MS_CLIENT_ID/SECRET (vidés =
  « non configuré »), redirect URI dérivé d'APP_BASE_URL.
- Catalogue : google/ms365 en natifs avec badge connecté/non connecté ;
  connector_fetch et Tester passent par l'API du fournisseur avec le token de
  l'utilisateur (user_id transmis par l'outil LLM).
- Menu + : « Se connecter » / « Déconnecter » sur la fiche, toast au retour du
  flux (URL nettoyée par history.replaceState). État dans la fiche du menu
  plutôt qu'une page dédiée.
- Tests : tests/test_v756_oauth_connectors.py (13), 0 appel réseau réel
  (_post_form / _api_get monkeypatchés) — state forgé refusé sans échange,
  tokens chiffrés en base, refresh, URL absolue refusée, 401/404, câblage menu ;
  test_v755 adapté (5 natifs). Suite complète 1319 verts (-n auto), ruff 0,
  eslint 0 erreur (19 warnings préexistants hors fichiers touchés).
This commit is contained in:
2026-10-07 08:29:22 -04:00
parent 18c72d77fe
commit 1d7750bda0
16 changed files with 983 additions and 33 deletions
+114 -7
View File
@@ -7,15 +7,17 @@ from __future__ import annotations
import asyncio
import json
import logging
import secrets
from datetime import UTC
from urllib.parse import urlparse
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
from app.auth.session import get_current_user
from app.config import settings
from app.db import get_conn
from app.services import connectors, skill_gallery
from app.services import connectors, oauth_connectors, skill_gallery
from app.services.agent_engine import AgentEngine, undo_action
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
from app.services.llm_config import (
@@ -640,9 +642,9 @@ def delete_skill(request: Request, skill_id: int):
@router.get("/connectors")
def list_connectors_route(request: Request):
"""Catalogue : 3 natifs (statut dérivé de la config) + personnels."""
_current_user_id(request)
return {"connectors": connectors.list_connectors()}
"""Catalogue : natifs (statut dérivé de la config) + personnels."""
user_id = _current_user_id(request)
return {"connectors": connectors.list_connectors(user_id)}
@router.post("/connectors")
@@ -685,16 +687,121 @@ def delete_connectors_route(request: Request, connector_id: int):
@router.post("/connectors/probe")
async def probe_connectors_route(request: Request, body: dict = Body(default={})):
"""Teste un connecteur (id, nom ou kind natif) et persiste le résultat."""
_current_user_id(request)
target = str(body.get("connector") or "").strip()
if not target:
raise HTTPException(status_code=400, detail="connector est requis")
user_id = _current_user_id(request)
try:
return await connectors.probe(target)
return await connectors.probe(target, user_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
# ── Connecteurs OAuth : Google / Microsoft 365 (v7.56.0) ──
# Flow : POST authorize (cookies state/verifier/next) → fournisseur →
# GET callback (échange du code, tokens chiffrés) → redirection vers la page
# d'origine. Le GET est en SAFE_METHODS : pas de CSRF (comme /auth/callback).
def _oauth_kind(kind: str) -> str:
if kind not in oauth_connectors.OAUTH_KINDS:
raise HTTPException(status_code=404, detail="Connecteur OAuth inconnu")
return kind
def _oauth_next(request: Request, kind: str, key: str, default: str = "/") -> str:
"""Chemin de retour same-origin (cookie `key` pour le callback)."""
raw = request.cookies.get(key, "") or default
path = urlparse(raw).path if raw.startswith("http") else raw
if not path.startswith("/") or path.startswith("//"):
return default
return path
@router.get("/connectors/oauth/{kind}/status")
def connector_oauth_status(request: Request, kind: str):
"""État du connecteur OAuth pour l'utilisateur courant."""
_oauth_kind(kind)
user_id = _current_user_id(request)
try:
oauth_connectors._client(kind)
configured, detail = True, ""
except ValueError as exc:
configured, detail = False, str(exc)
tok = oauth_connectors.tokens(kind, user_id)
return {
"kind": kind, "configured": configured, "configured_detail": detail,
"connected": bool(tok.get("access_token")), "scope": tok.get("scope", ""),
"expires_at": tok.get("expires_at", 0),
}
@router.post("/connectors/oauth/{kind}/authorize")
def connector_oauth_authorize(request: Request, kind: str):
"""URL d'autorisation (PKCE) + cookies d'état éphémères (10 min)."""
_oauth_kind(kind)
_current_user_id(request)
try:
flow = oauth_connectors.begin(kind)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
referer = request.headers.get("referer") or ""
next_path = urlparse(referer).path if referer else "/"
if not next_path.startswith("/") or next_path.startswith("//"):
next_path = "/"
secure = request.url.scheme == "https"
resp = JSONResponse({"url": flow["url"], "kind": kind})
for key, value in (
(f"fd_oauth_state_{kind}", flow["state"]),
(f"fd_oauth_verifier_{kind}", flow["verifier"]),
(f"fd_oauth_next_{kind}", next_path),
):
resp.set_cookie(key, value, max_age=600, httponly=True, samesite="lax", secure=secure)
return resp
@router.get("/connectors/oauth/{kind}/callback")
async def connector_oauth_callback(
request: Request, kind: str, code: str = "", state: str = "", error: str = ""
):
"""Reçoit le code du fournisseur, échange, stocke, renvoie sur la page."""
_oauth_kind(kind)
next_path = _oauth_next(request, kind, f"fd_oauth_next_{kind}")
expected = request.cookies.get(f"fd_oauth_state_{kind}", "")
verifier = request.cookies.get(f"fd_oauth_verifier_{kind}", "")
def _back(flag: str) -> RedirectResponse:
sep = "&" if "?" in next_path else "?"
resp = RedirectResponse(f"{next_path}{sep}{flag}", status_code=302)
for key in (f"fd_oauth_state_{kind}", f"fd_oauth_verifier_{kind}",
f"fd_oauth_next_{kind}"):
resp.delete_cookie(key, samesite="lax")
return resp
if error:
return _back("oauth_error=" + error[:80])
if not code or not expected or not secrets.compare_digest(expected, state):
return _back("oauth_error=state")
user = get_current_user(request)
if not user or not user.get("id"):
return _back("oauth_error=session")
try:
tokens = await oauth_connectors.complete(kind, code, verifier)
except ValueError as exc:
return _back("oauth_error=" + str(exc)[:80].replace(" ", "+"))
oauth_connectors.save(kind, int(user["id"]), tokens)
logger.info("Connector OAuth connected: %s (user #%s)", kind, user["id"])
return _back("oauth=connected")
@router.post("/connectors/oauth/{kind}/disconnect")
def connector_oauth_disconnect(request: Request, kind: str):
_oauth_kind(kind)
user_id = _current_user_id(request)
removed = oauth_connectors.clear(kind, user_id)
return {"kind": kind, "status": "disconnected" if removed else "not-connected"}
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──