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
+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))