feat: connecteurs Google + Microsoft 365 (OAuth2 PKCE) — phase 6/8 (v7.56.0)
- 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:
+31
-10
@@ -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"
|
||||
|
||||
@@ -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]}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user