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
+8
View File
@@ -126,6 +126,14 @@ class Settings(BaseSettings):
agent_max_tokens_budget: int = 500000
agent_run_timeout_seconds: int = 300
# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ──
# Client OAuth2 à créer dans les consoles : Google Cloud (OAuth client web)
# et Entra ID (application web). Le redirect URI est dérivé d'APP_BASE_URL.
google_client_id: str = ""
google_client_secret: str = ""
ms_client_id: str = ""
ms_client_secret: str = ""
# ── Mémoire de l'agent (v7.54.0) ──
# État initial du toggle « Mémoire » à la création d'une conversation ;
# le basculement se fait ensuite par PATCH /api/agent/conversations/{id}.
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.55.0",
version="7.56.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+14
View File
@@ -1586,6 +1586,20 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
)
@register(33, "v7.56.0: tokens OAuth des connecteurs (Google / M365)")
def _migration_connector_tokens(conn: sqlite3.Connection) -> None:
"""Tokens OAuth par (kind, utilisateur), chiffrés Fernet côté service."""
conn.execute(
"""CREATE TABLE IF NOT EXISTS connector_tokens (
kind TEXT NOT NULL,
user_id INTEGER NOT NULL,
tokens_enc TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (kind, user_id)
)"""
)
@register(32, "v7.55.0: connecteurs de l'agent (socle)")
def _migration_agent_connectors(conn: sqlite3.Connection) -> None:
"""Catalogue de connecteurs : 3 natifs servis à la volée + personnels ici.
+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 👍 / 👎) ──
+31 -10
View File
@@ -14,18 +14,30 @@ import logging
from app.config import settings
from app.db import get_conn
from app.services import oauth_connectors
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
logger = logging.getLogger(__name__)
NATIVE_KINDS = ("gitea", "github", "web")
NATIVE_KINDS = ("gitea", "github", "web", "google", "ms365")
OAUTH_KINDS = oauth_connectors.OAUTH_KINDS
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
# ── Natifs (config, sans réseau) ─────────────────────────────────────────────
def native_state(kind: str) -> tuple[str, str]:
def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
"""(status, detail) d'un connecteur natif — dérivé de la config, sans réseau."""
if kind in OAUTH_KINDS:
try:
oauth_connectors._client(kind) # lève si client_id/secret absents
except ValueError as exc:
return ("missing", str(exc))
tok = oauth_connectors.tokens(kind, user_id)
if tok.get("access_token"):
scope = (tok.get("scope") or "").split()
return ("ok", f"connecté · {len(scope)} scope(s)")
return ("missing", "non connecté — lancer la connexion OAuth")
if kind == "gitea":
ok = bool(settings.gitea_url) and settings.gitea_token not in ("", "change-me")
return ("ok", settings.gitea_url if ok else "GITEA_TOKEN non configuré")
@@ -36,15 +48,17 @@ def native_state(kind: str) -> tuple[str, str]:
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
def list_connectors() -> list[dict]:
"""Catalogue : 3 natifs (toujours présents) + les connecteurs personnels."""
def list_connectors(user_id: int | None = None) -> list[dict]:
"""Catalogue : natifs (toujours présents) + connecteurs personnels."""
out: list[dict] = []
for kind in NATIVE_KINDS:
status, detail = native_state(kind)
status, detail = native_state(kind, user_id)
out.append({
"id": None, "builtin": True, "kind": kind, "name": kind.capitalize(),
"id": None, "builtin": True, "kind": kind,
"name": (oauth_connectors.PROVIDERS[kind]["name"] if kind in OAUTH_KINDS
else kind.capitalize()),
"url": "", "enabled": True, "status": status, "detail": detail,
"has_secret": False,
"has_secret": False, "oauth": kind in OAUTH_KINDS,
})
with get_conn() as conn:
rows = conn.execute(
@@ -183,11 +197,15 @@ def _resolve(connector: str) -> tuple[str, str | int]:
raise ValueError(f"Connecteur inconnu: {token}")
async def probe(connector: str) -> dict:
async def probe(connector: str, user_id: int | None = None) -> dict:
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
kind, ref = _resolve(connector)
try:
if kind == "gitea":
if kind in OAUTH_KINDS:
res = await oauth_connectors.api_get(kind, user_id,
oauth_connectors.PROVIDERS[kind]["probe_path"])
status, detail = res["status"], res["text"][:200]
elif kind == "gitea":
code, _ = await _get(f"{settings.gitea_url.rstrip('/')}/api/v1/version",
{"Authorization": f"token {settings.gitea_token}"})
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
@@ -221,10 +239,13 @@ def _gh_headers() -> dict:
return headers
async def connector_fetch(connector: str, path: str = "", query: str = "") -> dict:
async def connector_fetch(connector: str, path: str = "", query: str = "",
user_id: int | None = None) -> dict:
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
kind, ref = _resolve(connector)
path = (path or "").strip()
if kind in OAUTH_KINDS:
return await oauth_connectors.api_get(kind, user_id, path)
if kind == "gitea":
base = settings.gitea_url.rstrip("/")
url = f"{base}/{path.lstrip('/')}" if path else f"{base}/api/v1/version"
+254
View File
@@ -0,0 +1,254 @@
"""Connecteurs OAuth Google / Microsoft 365 (v7.56.0 — phase 6).
Flow : ``begin()`` (URL d'autorisation PKCE S256 + state) → callback
``complete()`` (échange du code) → tokens chiffrés Fernet → ``api_get()`` avec
refresh automatique.
Réutilise : `_encrypt_tokens` / `_decrypt_tokens` (`calendar_sync`, déjà Fernet)
et `shared_client` (A42). Les scopes sont **figés en lecture seule**.
ponytail : 2 providers décrits par 1 dict (pas de classe par provider) ; les
scopes au choix et un écran de connexion dédié arriveront si le besoin se
présente — l'état vit dans le catalogue du menu +.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import secrets
import time
from urllib.parse import urlencode
from app.config import settings
from app.db import get_conn
from app.services.calendar_sync import _decrypt_tokens, _encrypt_tokens
logger = logging.getLogger(__name__)
PROVIDERS: dict[str, dict] = {
"google": {
"name": "Google (Drive · Gmail · Calendar)",
"authorize": "https://accounts.google.com/o/oauth2/v2/auth",
"token": "https://oauth2.googleapis.com/token",
"api": "https://www.googleapis.com",
"scopes": [
"openid", "email", "profile",
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
],
"extra": {"access_type": "offline", "include_granted_scopes": "true"},
"probe_path": "/oauth2/v3/userinfo",
},
"ms365": {
"name": "Microsoft 365 (Outlook · OneDrive)",
"authorize": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
"token": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
"api": "https://graph.microsoft.com/v1.0",
"scopes": [
"openid", "email", "profile", "offline_access",
"User.Read", "Files.Read", "Mail.Read", "Calendars.Read",
],
"extra": {"response_mode": "query", "prompt": "consent"},
"probe_path": "/me",
},
}
OAUTH_KINDS = tuple(PROVIDERS)
# l'access token est renouvelé 60 s avant expiration
_REFRESH_SKEW = 60
# ── Config client ───────────────────────────────────────────────────────────
def _client(kind: str) -> tuple[str, str, str]:
"""(client_id, client_secret, redirect_uri) — lève si non configuré."""
if kind not in PROVIDERS:
raise ValueError(f"Connecteur OAuth inconnu: {kind}")
if kind == "google":
cid, secret = settings.google_client_id, settings.google_client_secret
else:
cid, secret = settings.ms_client_id, settings.ms_client_secret
if not cid or not secret:
raise ValueError(
f"Connecteur {kind} non configuré (GOOGLE_CLIENT_ID/SECRET ou MS_CLIENT_ID/SECRET)"
)
redirect = f"{settings.app_base_url.rstrip('/')}/api/agent/connectors/oauth/{kind}/callback"
return cid, secret, redirect
def callback_path(kind: str) -> str:
return f"/api/agent/connectors/oauth/{kind}/callback"
def _pkce_pair() -> tuple[str, str]:
verifier = secrets.token_urlsafe(48)
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return verifier, base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
# ── Flow ────────────────────────────────────────────────────────────────────
def begin(kind: str) -> dict:
"""URL d'autorisation + state + code_verifier (le route met les cookies)."""
cid, _secret, redirect = _client(kind)
verifier, challenge = _pkce_pair()
state = secrets.token_urlsafe(24)
params = {
"client_id": cid,
"redirect_uri": redirect,
"response_type": "code",
"scope": " ".join(PROVIDERS[kind]["scopes"]),
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
params.update(PROVIDERS[kind].get("extra", {}))
return {
"url": f"{PROVIDERS[kind]['authorize']}?{urlencode(params)}",
"state": state,
"verifier": verifier,
"redirect_uri": redirect,
}
def _normalize(data: dict) -> dict:
expires_in = int(data.get("expires_in") or 3600)
return {
"access_token": str(data.get("access_token") or ""),
"refresh_token": str(data.get("refresh_token") or ""),
"scope": str(data.get("scope") or ""),
"expires_at": int(time.time()) + expires_in,
}
async def complete(kind: str, code: str, verifier: str) -> dict:
"""Échange le code contre des tokens (aucun réseau ici hors `_post_form`)."""
cid, secret, redirect = _client(kind)
data = await _post_form(PROVIDERS[kind]["token"], {
"client_id": cid,
"client_secret": secret,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect,
"code_verifier": verifier,
})
tokens = _normalize(data)
if not tokens["access_token"]:
raise ValueError(str(data.get("error_description") or data.get("error") or "échec de l'échange du code"))
return tokens
# ── Stockage (chiffré Fernet, comme calendar_sync) ──────────────────────────
def save(kind: str, user_id: int, tokens: dict) -> None:
with get_conn() as conn:
conn.execute(
"INSERT INTO connector_tokens (kind, user_id, tokens_enc, updated_at) "
"VALUES (?,?,?,CURRENT_TIMESTAMP) "
"ON CONFLICT(kind, user_id) DO UPDATE SET "
"tokens_enc=excluded.tokens_enc, updated_at=CURRENT_TIMESTAMP",
(kind, user_id, _encrypt_tokens(tokens)),
)
conn.commit()
def tokens(kind: str, user_id: int | None) -> dict:
if not user_id:
return {}
with get_conn() as conn:
row = conn.execute(
"SELECT tokens_enc FROM connector_tokens WHERE kind=? AND user_id=?",
(kind, user_id),
).fetchone()
return _decrypt_tokens(row["tokens_enc"]) if row else {}
def clear(kind: str, user_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute(
"DELETE FROM connector_tokens WHERE kind=? AND user_id=?", (kind, user_id)
)
conn.commit()
return cur.rowcount > 0
def is_connected(kind: str, user_id: int | None) -> bool:
return bool(tokens(kind, user_id).get("access_token"))
# ── Réseau ──────────────────────────────────────────────────────────────────
async def _post_form(url: str, data: dict) -> dict:
"""POST x-www-form-urlencoded vers le token endpoint → dict JSON.
Point d'injection des tests (on monkeypatche ``oauth_connectors._post_form``).
"""
from app.services.http_client import shared_client
async with shared_client(timeout=15, headers={"Accept": "application/json"}) as client:
resp = await client.post(url, data=data)
return resp.json()
async def _api_get(url: str, headers: dict) -> tuple[int, str]:
"""GET d'une API fournisseur — 2ᵉ point d'injection des tests."""
from app.services.http_client import shared_client
async with shared_client(timeout=15, headers=headers) as client:
resp = await client.get(url)
return resp.status_code, (resp.text or "")[:20000]
async def access_token(kind: str, user_id: int | None) -> str:
"""Access token valide, rafraîchi (refresh_token) si nécessaire."""
tok = tokens(kind, user_id)
if not tok.get("access_token"):
return ""
if tok.get("expires_at", 0) > time.time() + _REFRESH_SKEW:
return tok["access_token"]
if not tok.get("refresh_token"):
return "" # expiré sans refresh → à reconnexion
try:
cid, secret, redirect = _client(kind)
data = await _post_form(PROVIDERS[kind]["token"], {
"client_id": cid,
"client_secret": secret,
"grant_type": "refresh_token",
"refresh_token": tok["refresh_token"],
"redirect_uri": redirect,
})
fresh = _normalize(data)
if not fresh["access_token"]:
raise ValueError(data.get("error_description") or "refresh refusé")
# Google ne renvoie parfois pas de nouveau refresh_token : on garde l'ancien
fresh["refresh_token"] = fresh["refresh_token"] or tok["refresh_token"]
save(kind, int(user_id), fresh)
return fresh["access_token"]
except Exception as exc: # noqa: BLE001 — jamais d'exception qui casse un run
logger.warning("OAuth refresh failed (%s): %s", kind, exc)
return ""
async def api_get(kind: str, user_id: int | None, path: str = "") -> dict:
"""GET sur l'API du fournisseur avec le token de l'utilisateur."""
if kind not in PROVIDERS:
return {"status": "error", "text": f"Connecteur OAuth inconnu: {kind}"}
access = await access_token(kind, user_id)
if not access:
return {"status": "error", "text": f"{kind} non connecté (lancez la connexion OAuth)"}
if "://" in (path or ""):
return {"status": "error", "text": "path doit être relatif (pas d'URL absolue)"}
# base fixe + chemin : pas d'urljoin (une URL absolue ne peut pas dévier
# l'hôte), puis validation SSRF par principe.
url = PROVIDERS[kind]["api"].rstrip("/") + "/" + (path or "").lstrip("/")
from app.services.importers.url_fetch import _validate_url
try:
_validate_url(url)
except ValueError as exc:
return {"status": "error", "text": str(exc)}
code, text = await _api_get(url, {"Authorization": f"Bearer {access}"})
if code >= 400:
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
return {"status": "ok", "text": text[:20000]}
+1
View File
@@ -1099,6 +1099,7 @@ class ConnectorFetch(Tool):
str(args.get("connector") or ""),
str(args.get("path") or ""),
str(args.get("query") or ""),
user_id=user_id,
)
except ValueError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))