From 1d7750bda05eccd5fe14fcab896ee5904804f9f9 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 7 Oct 2026 08:29:22 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20connecteurs=20Google=20+=20Microsoft=20?= =?UTF-8?q?365=20(OAuth2=20PKCE)=20=E2=80=94=20phase=206/8=20(v7.56.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .env.example | 10 ++ CHANGELOG.md | 45 +++++ ROADMAP.md | 35 +++- VERSION | 2 +- app/config.py | 8 + app/main.py | 2 +- app/migrations.py | 14 ++ app/routers/agent.py | 121 ++++++++++++- app/services/connectors.py | 41 +++-- app/services/oauth_connectors.py | 254 ++++++++++++++++++++++++++++ app/services/tool_registry.py | 1 + docs/V74_Agent_Plus_Menu.md | 4 +- docs/openapi-v2.json | 197 ++++++++++++++++++++- static/js/agent_panel_2.js | 46 +++++ tests/test_v755_connectors.py | 5 +- tests/test_v756_oauth_connectors.py | 231 +++++++++++++++++++++++++ 16 files changed, 983 insertions(+), 33 deletions(-) create mode 100644 app/services/oauth_connectors.py create mode 100644 tests/test_v756_oauth_connectors.py diff --git a/.env.example b/.env.example index 0dc0445..a8a157a 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,16 @@ GITHUB_OAUTH_CLIENT_SECRET= # Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback OAUTH_REDIRECT_URI= +# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ── +# Vidés = connecteur « non configuré » (le menu + affiche la raison). +# Le redirect URI à enregistrer est dérivé d'APP_BASE_URL : +# {APP_BASE_URL}/api/agent/connectors/oauth/google/callback +# {APP_BASE_URL}/api/agent/connectors/oauth/ms365/callback +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +MS_CLIENT_ID= +MS_CLIENT_SECRET= + # ── App ── APP_SECRET_KEY=change-me-to-random APP_HOST=0.0.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ad6cad..d6a7e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog - FlowDeck +## v7.56.0 (2026-10-06) — Connecteurs : Google + Microsoft 365 (phase 6/8) + +### Added + +- **`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** ; + - **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 avant + expiration, `refresh_token` conservé si le fournisseur n'en renvoie pas) ; + `api_get()` → lecture bornée, `path` absolu refusé + validation SSRF ; + - tokens chiffrés **Fernet** via `_encrypt_tokens` de `calendar_sync` + (réutilisation, aucune nouvelle dépendance) dans la table + **`connector_tokens`** (migration **33**, PK `(kind, user_id)`). +- **4 routes OAuth** (`/api/agent/connectors/oauth/{kind}/…`) : `status`, + `authorize` (cookies d'état HttpOnly 10 min + retour same-origin validé), + `callback` (GET = SAFE_METHODS, `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** : les 2 nouveaux natifs apparaissent avec le badge + « non connecté — lancer la connexion OAuth » / « connecté · N scope(s) » ; + `connector_fetch` et `Tester` passent par l'API Graph/Google avec le token de + l'utilisateur (outil LLM = `user_id` désormais transmis). +- **Menu +** : « Se connecter » (redirige vers le fournisseur) / « Déconnecter » + sur la fiche du connecteur, + toast au retour du flux (URL nettoyée via + `history.replaceState`). + +### Tests + +- `tests/test_v756_oauth_connectors.py` — **13 tests** : URL d'autorisation + (PKCE, scopes, redirect URI, cookies), 400 non configuré, callback → tokens + **chiffrés** en base + redirection + `status.connected`, **state forgé refusé + sans aucun appel réseau**, callback sans session → `oauth_error=session`, + refresh (grant_type, conservation du refresh_token), `api_get` (Bearer + hôte + fixe + URL absolue refusée), non connecté, déconnexion, catalogue, outil LLM, + 401 sans session, 404 kind inconnu, câblage menu. Aucun appel réseau réel + (`_post_form` / `_api_get` monkeypatchés). +- `test_v755` adapté : **5 natifs** (gitea, github, web, google, ms365). +- **Suite complète : 1319 passed / 0 failed** (`-n auto`), `ruff` 0, + `eslint` 0 problème. + + ## v7.55.0 (2026-10-06) — Menu + : Connecteurs — socle (phase 5/8) ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 9ebbd7c..f84d5b2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -284,7 +284,7 @@ Propriétés custom, AI keywords, sync API, 12 tables DB --- -## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-5 ✅ 2026-10-06 · phases 6-8 planifiées) +## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-6 ✅ 2026-10-06 · phases 7-8 planifiées) > **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition) > un menu à sections, tel que demandé : @@ -446,13 +446,30 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j. - [x] **Livraison** — `VERSION` + `app/main.py` = **7.55.0**, OpenAPI régénéré (519 chemins), CHANGELOG, ce ROADMAP -### Phase 6 — v7.56.0 — Connecteurs : Google + Microsoft 365 · effort L +### Phase 6 — v7.56.0 — Connecteurs : Google + Microsoft 365 · effort L ✅ (livrée 2026-10-06) -- [ ] **OAuth2 PKCE Google** — Drive, Gmail, Calendar : connexion, refresh, déconnexion, - lecture limitée aux sources choisies -- [ ] **OAuth2 Microsoft (Graph)** — Outlook / OneDrive / SharePoint -- [ ] **Écran connecteur** — état, scopes, dernière synchro, bouton déconnecter -- [ ] **Tests** — callbacks, refresh, échec, **aucun appel réseau réel** (transport injecté) +- [x] **OAuth2 PKCE Google** — Drive / Gmail / Calendar **en lecture seule** ; + connexion (`begin()` → URL + state + `code_verifier` S256), échange du code, + **refresh automatique** (60 s de marge, `refresh_token` conservé si absent de + la réponse), déconnexion — table `connector_tokens` (migration **33**, + PK `(kind, user_id)`), tokens chiffrés Fernet **réutilisant** + `calendar_sync._encrypt_tokens` (zéro dépendance ajoutée) +- [x] **OAuth2 Microsoft (Graph)** — `Files.Read` / `Mail.Read` / `Calendars.Read` + + `offline_access`, même code (2 providers décrits par **1 dict**) +- [x] **Écran connecteur** — **pas de page dédiée** : l'état vit dans la fiche du + menu + (badge « connecté · N scope(s) » / « non connecté »), boutons + **Se connecter / Déconnecter**, toast au retour du flux (`?oauth=connected` / + `?oauth_error=` nettoyés par `history.replaceState`) +- [x] **Tests** — `tests/test_v756_oauth_connectors.py` : **13 tests**, **0 appel + réseau réel** (`_post_form` / `_api_get` monkeypatchés) — URL d'autorisation + (PKCE, scopes, redirect URI, cookies), 400 non configuré, callback → tokens + chiffrés + redirection, **state forgé refusé sans échange**, callback sans + session, refresh, `api_get` (Bearer, URL absolue refusée), déconnexion, + catalogue, outil LLM, 401/404, câblage menu ; `test_v755` adapté (5 natifs) ; + **suite complète 1319 verts**, `ruff` 0, `eslint` 0 +- [x] **Livraison** — `VERSION` + `app/main.py` = **7.56.0**, `.env.example` + (GOOGLE_/MS_ CLIENT_ID/SECRET + redirect URI), OpenAPI régénéré (523 chemins), + CHANGELOG, ce ROADMAP ### Phase 7 — v7.57.0 — Connecteurs : Discord, Telegram, Teams, MCP · effort L @@ -472,8 +489,8 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j. --- -*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 5 livrées le -2026-10-06 (v7.51.0 → v7.55.0)**, phases 6-8 à valider une par une avant « go ». +*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 6 livrées le +2026-10-06 (v7.51.0 → v7.56.0)**, phases 7-8 à valider une par une avant « go ». Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.* --- diff --git a/VERSION b/VERSION index 3c08909..7c6760b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.55.0 +7.56.0 diff --git a/app/config.py b/app/config.py index bd2bd11..921354d 100644 --- a/app/config.py +++ b/app/config.py @@ -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}. diff --git a/app/main.py b/app/main.py index 2827062..fae41b5 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/migrations.py b/app/migrations.py index 8d0b475..2bc9f77 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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. diff --git a/app/routers/agent.py b/app/routers/agent.py index dff5acc..2c76d6d 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -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 👍 / 👎) ── diff --git a/app/services/connectors.py b/app/services/connectors.py index ab30c20..9f75013 100644 --- a/app/services/connectors.py +++ b/app/services/connectors.py @@ -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" diff --git a/app/services/oauth_connectors.py b/app/services/oauth_connectors.py new file mode 100644 index 0000000..4b302c6 --- /dev/null +++ b/app/services/oauth_connectors.py @@ -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]} diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py index 55a1a0c..547f337 100644 --- a/app/services/tool_registry.py +++ b/app/services/tool_registry.py @@ -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)) diff --git a/docs/V74_Agent_Plus_Menu.md b/docs/V74_Agent_Plus_Menu.md index 40ac95b..786aa5a 100644 --- a/docs/V74_Agent_Plus_Menu.md +++ b/docs/V74_Agent_Plus_Menu.md @@ -1,7 +1,7 @@ # V74 — Menu + de l'assistant : hub de contexte (design) -> **Statut** : design — **phases 1 à 5 livrées (v7.51.0 → v7.55.0, 2026-10-06)** ; -> phases 6-8 restent à livrer. La **mémoire** a été simplifiée par rapport au +> **Statut** : design — **phases 1 à 6 livrées (v7.51.0 → v7.56.0, 2026-10-06)** ; +> phases 7-8 restent à livrer. La **mémoire** a été simplifiée par rapport au > modèle ici : une seule ligne résumé **par conversation** (pas de lignes > `workspace_id NULL` — rien ne les écrivait), extraction déterministe sans LLM. Adaptations faites en cours de route : le parcours > utilise `GET /api/nav/menu?parent_id=` (un niveau par appel, contrat diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 3294b10..e4323e3 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.55.0" + "version": "7.56.0" }, "paths": { "/auth/register": { @@ -16246,7 +16246,7 @@ "agent" ], "summary": "List Connectors Route", - "description": "Catalogue : 3 natifs (statut dérivé de la config) + personnels.", + "description": "Catalogue : natifs (statut dérivé de la config) + personnels.", "operationId": "list_connectors_route_api_agent_connectors_get", "responses": { "200": { @@ -16429,6 +16429,199 @@ } } }, + "/api/agent/connectors/oauth/{kind}/status": { + "get": { + "tags": [ + "agent" + ], + "summary": "Connector Oauth Status", + "description": "État du connecteur OAuth pour l'utilisateur courant.", + "operationId": "connector_oauth_status_api_agent_connectors_oauth__kind__status_get", + "parameters": [ + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Kind" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/agent/connectors/oauth/{kind}/authorize": { + "post": { + "tags": [ + "agent" + ], + "summary": "Connector Oauth Authorize", + "description": "URL d'autorisation (PKCE) + cookies d'état éphémères (10 min).", + "operationId": "connector_oauth_authorize_api_agent_connectors_oauth__kind__authorize_post", + "parameters": [ + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Kind" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/agent/connectors/oauth/{kind}/callback": { + "get": { + "tags": [ + "agent" + ], + "summary": "Connector Oauth Callback", + "description": "Reçoit le code du fournisseur, échange, stocke, renvoie sur la page.", + "operationId": "connector_oauth_callback_api_agent_connectors_oauth__kind__callback_get", + "parameters": [ + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Kind" + } + }, + { + "name": "code", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "", + "title": "Code" + } + }, + { + "name": "state", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "", + "title": "State" + } + }, + { + "name": "error", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "", + "title": "Error" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/agent/connectors/oauth/{kind}/disconnect": { + "post": { + "tags": [ + "agent" + ], + "summary": "Connector Oauth Disconnect", + "operationId": "connector_oauth_disconnect_api_agent_connectors_oauth__kind__disconnect_post", + "parameters": [ + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Kind" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/agent/mentions": { "get": { "tags": [ diff --git a/static/js/agent_panel_2.js b/static/js/agent_panel_2.js index 2527eda..6365351 100644 --- a/static/js/agent_panel_2.js +++ b/static/js/agent_panel_2.js @@ -86,6 +86,18 @@ init(){ var self = this; var el = document.getElementById('fd-agent-panel'); + // Retour du flux OAuth (Google / M365) : signaler le résultat puis nettoyer l'URL. + try{ + var qs = new URLSearchParams(window.location.search); + if(qs.get('oauth') || qs.get('oauth_error')){ + this.toast(qs.get('oauth_error') + ? 'Connexion du connecteur refusée : ' + qs.get('oauth_error') + : 'Connecteur connecté.', !!qs.get('oauth_error')); + qs.delete('oauth'); qs.delete('oauth_error'); + window.history.replaceState(null, '', window.location.pathname + + (qs.toString() ? '?' + qs.toString() : '') + window.location.hash); + } + }catch{ /* volontaire */ } function applyState(){ if(el){ if(self.open){ el.classList.add('open'); el.setAttribute('aria-hidden','false'); } else { el.classList.remove('open'); el.setAttribute('aria-hidden','true'); } } @@ -1281,6 +1293,14 @@ {key:'cn-probe', icon:'↻', label:'Tester le connecteur', sub:'Appel de contrôle — status + détail', action:'connector-probe'} ]; + if(c.oauth){ + var connected = c.status === 'ok'; + items.push({key:'cn-oauth', icon: connected ? '🔓' : '🔑', + label: connected ? 'Déconnecter' : 'Se connecter', + sub: connected ? 'Retire les autorisations de ce compte' + : 'Ouvre la page de connexion du fournisseur', + action: connected ? 'connector-disconnect' : 'connector-connect'}); + } if(!c.builtin){ items.push({key:'cn-toggle', icon: c.enabled ? '⏸' : '▶', label: c.enabled ? 'Désactiver' : 'Activer', @@ -1319,6 +1339,30 @@ self.fetchConnectors(); }).catch(function(){ self.toast('Bascule impossible (réseau).', true); }); }, + // ── Connexion OAuth Google / M365 (v7.56.0) ── + connectorConnect(){ + var self = this, c = this.connector; + if(!c || !c.oauth) return; + fetch('/api/agent/connectors/oauth/' + c.kind + '/authorize', { + method:'POST', headers:{'X-CSRF-Token': getCsrf()} + }).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); }) + .then(function(res){ + if(!res.ok){ self.toast((res.d && res.d.detail) || 'Connexion impossible.', true); return; } + window.location.href = res.d.url; // → fournisseur → callback → retour ici + }).catch(function(){ self.toast('Connexion impossible (réseau).', true); }); + }, + connectorDisconnect(){ + var self = this, c = this.connector; + if(!c || !c.oauth) return; + fetch('/api/agent/connectors/oauth/' + c.kind + '/disconnect', { + method:'POST', headers:{'X-CSRF-Token': getCsrf()} + }).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); }) + .then(function(res){ + if(!res.ok){ self.toast('Déconnexion impossible.', true); return; } + self.toast('Connecteur déconnecté.'); + self.fetchConnectors(); + }).catch(function(){ self.toast('Déconnexion impossible (réseau).', true); }); + }, connectorDelete(){ var self = this, c = this.connector; if(!c || c.builtin || c.id == null) return; @@ -1520,6 +1564,8 @@ if(it.action === 'connector-probe'){ this.connectorProbe(); return; } if(it.action === 'connector-toggle'){ this.connectorToggle(); return; } if(it.action === 'connector-delete'){ this.connectorDelete(); return; } + if(it.action === 'connector-connect'){ this.connectorConnect(); return; } + if(it.action === 'connector-disconnect'){ this.connectorDisconnect(); return; } // ── Design System – Canevas (v7.53.0) ── if(it.action === 'canvases'){ this.fetchCanvases(); this.plusSection = 'canvases'; this.plusFocus = 0; return; diff --git a/tests/test_v755_connectors.py b/tests/test_v755_connectors.py index 623e93c..f31b48c 100644 --- a/tests/test_v755_connectors.py +++ b/tests/test_v755_connectors.py @@ -27,12 +27,15 @@ def _create(client, **kw): def test_native_connectors_always_listed(client): rows = client.get("/api/agent/connectors").json()["connectors"] native = {r["kind"]: r for r in rows if r["builtin"]} - assert set(native) == {"gitea", "github", "web"} + assert set(native) == {"gitea", "github", "web", "google", "ms365"} for r in native.values(): assert r["id"] is None assert r["status"] in ("ok", "missing") assert r["enabled"] is True assert native["web"]["status"] == "ok" + # les connecteurs OAuth n'existent que si configurés (client_id/secret) + assert native["google"]["oauth"] is True + assert native["google"]["status"] == "missing" def test_create_custom_connector_never_returns_secret(client): diff --git a/tests/test_v756_oauth_connectors.py b/tests/test_v756_oauth_connectors.py new file mode 100644 index 0000000..0838c15 --- /dev/null +++ b/tests/test_v756_oauth_connectors.py @@ -0,0 +1,231 @@ +"""v7.56.0 — Connecteurs OAuth Google / Microsoft 365 (PKCE, tokens, refresh).""" + +import asyncio +import time +from pathlib import Path + +import pytest +from conftest import anon_csrf + +from app.db import get_conn +from app.services import oauth_connectors + +ROOT = Path(__file__).resolve().parent.parent +JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js" + + +@pytest.fixture +def configured(monkeypatch): + """Clients OAuth fictifs (aucune clé réelle nécessaire pour les tests).""" + from app.config import settings + monkeypatch.setattr(settings, "google_client_id", "gid-test") + monkeypatch.setattr(settings, "google_client_secret", "gsec-test") + monkeypatch.setattr(settings, "ms_client_id", "mid-test") + monkeypatch.setattr(settings, "ms_client_secret", "msec-test") + monkeypatch.setattr(settings, "app_base_url", "https://flowdeck.example") + return settings + + +def _begin(client, kind="google"): + r = client.post(f"/api/agent/connectors/oauth/{kind}/authorize") + assert r.status_code == 200, r.text + return r.json() + + +def test_authorize_url_has_pkce_scope_and_state(client, configured): + data = _begin(client) + url = data["url"] + assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth?") + assert "client_id=gid-test" in url + assert "code_challenge=" in url and "code_challenge_method=S256" in url + assert "drive.readonly" in url and "gmail.readonly" in url and "calendar.readonly" in url + assert "state=" in url + assert "redirect_uri=" in url and "api%2Fagent%2Fconnectors%2Foauth%2Fgoogle%2Fcallback" in url + # les cookies d'état partent avec la réponse (10 min, HttpOnly) + for key in ("fd_oauth_state_google", "fd_oauth_verifier_google", "fd_oauth_next_google"): + assert key in client.cookies + + +def test_authorize_without_client_config_is_400(client): + r = client.post("/api/agent/connectors/oauth/google/authorize") + assert r.status_code == 400 + assert "non configuré" in r.json()["detail"] + + +def test_callback_saves_tokens_encrypted_and_redirects(client, configured, monkeypatch): + _begin(client) + state = client.cookies["fd_oauth_state_google"] + + async def fake_post(url, form=None, **kw): + assert url == "https://oauth2.googleapis.com/token" + assert form["grant_type"] == "authorization_code" + assert form["code"] == "abc123" + assert form["code_verifier"] == client.cookies["fd_oauth_verifier_google"] + return {"access_token": "at-1", "refresh_token": "rt-1", + "expires_in": 3600, "scope": "openid email"} + + monkeypatch.setattr(oauth_connectors, "_post_form", fake_post) + resp = client.get( + "/api/agent/connectors/oauth/google/callback", + params={"code": "abc123", "state": state}, + follow_redirects=False, + ) + assert resp.status_code == 302 + assert "oauth=connected" in resp.headers["location"] + + with get_conn() as conn: + row = conn.execute("SELECT tokens_enc FROM connector_tokens").fetchone() + assert row and "at-1" not in row["tokens_enc"] # chiffré + assert oauth_connectors.tokens("google", 1)["access_token"] == "at-1" + + status = client.get("/api/agent/connectors/oauth/google/status").json() + assert status["connected"] is True and status["configured"] is True + + +def test_callback_rejects_state_mismatch(client, configured, monkeypatch): + called = [] + + async def fake_post(url, form=None, **kw): + called.append(form) + return {"access_token": "at"} + + monkeypatch.setattr(oauth_connectors, "_post_form", fake_post) + resp = client.get( + "/api/agent/connectors/oauth/google/callback", + params={"code": "abc", "state": "forged"}, # != cookie + follow_redirects=False, + ) + assert resp.status_code == 302 and "oauth_error=state" in resp.headers["location"] + assert called == [] + assert not oauth_connectors.is_connected("google", 1) + + +def test_callback_without_session_redirects(client, configured, monkeypatch): + _begin(client) + state = client.cookies["fd_oauth_state_google"] + + async def fake_post(url, form=None, **kw): + return {"access_token": "at"} + + monkeypatch.setattr(oauth_connectors, "_post_form", fake_post) + # on retire la SEULE session (anon_csrf effacerait aussi le cookie d'état OAuth) + client.cookies.delete("flowdeck_session") + client.auth = None + resp = client.get( + "/api/agent/connectors/oauth/google/callback", + params={"code": "abc", "state": state}, + follow_redirects=False, + ) + assert resp.status_code == 302 and "oauth_error=session" in resp.headers["location"] + + +def test_refresh_token_flow(client, configured, monkeypatch): + oauth_connectors.save("google", 1, { + "access_token": "old", "refresh_token": "rt-keep", + "expires_at": int(time.time()) - 10, "scope": "openid", + }) + calls = [] + + async def fake_post(url, form=None, **kw): + calls.append(form) + return {"access_token": "new", "expires_in": 3600} # pas de refresh_token + + monkeypatch.setattr(oauth_connectors, "_post_form", fake_post) + access = asyncio.run(oauth_connectors.access_token("google", 1)) + assert access == "new" + assert calls[0]["grant_type"] == "refresh_token" + assert calls[0]["refresh_token"] == "rt-keep" + stored = oauth_connectors.tokens("google", 1) + assert stored["refresh_token"] == "rt-keep" # conservé si absent de la réponse + assert stored["expires_at"] > time.time() + + +def test_api_get_bearer_and_refuses_absolute_url(client, configured, monkeypatch): + oauth_connectors.save("google", 1, {"access_token": "at", "refresh_token": "", + "expires_at": int(time.time()) + 3600, + "scope": "openid"}) + seen = [] + + async def fake_get(url, headers=None): + seen.append((url, headers)) + return 200, '{"emails": 3}' + + monkeypatch.setattr(oauth_connectors, "_api_get", fake_get) + res = asyncio.run(oauth_connectors.api_get("google", 1, "/gmail/v1/users/me/profile")) + assert res["status"] == "ok" + assert seen[0][0].startswith("https://www.googleapis.com/gmail/v1/") + assert seen[0][1]["Authorization"] == "Bearer at" + + # une URL absolue dans `path` ne doit pas dévier (hôte fixe + validation) + res2 = asyncio.run(oauth_connectors.api_get("google", 1, "https://evil.example/x")) + assert res2["status"] == "error" + assert len(seen) == 1 # aucun appel réseau + + +def test_api_get_requires_connection(client, configured): + res = asyncio.run(oauth_connectors.api_get("google", 1, "/me")) + assert res["status"] == "error" and "non connecté" in res["text"] + + +def test_disconnect_clears_tokens(client, configured): + oauth_connectors.save("ms365", 1, {"access_token": "at", "expires_at": 1, "scope": ""}) + assert oauth_connectors.is_connected("ms365", 1) + r = client.post("/api/agent/connectors/oauth/ms365/disconnect") + assert r.status_code == 200 and r.json()["status"] == "disconnected" + assert not oauth_connectors.is_connected("ms365", 1) + + +def test_catalogue_marks_oauth_connectors(client, configured): + rows = client.get("/api/agent/connectors").json()["connectors"] + by_kind = {r["kind"]: r for r in rows if r["builtin"]} + assert "google" in by_kind and "ms365" in by_kind + assert by_kind["google"]["oauth"] is True + assert by_kind["google"]["status"] == "missing" # configuré mais pas connecté + assert "non connecté" in by_kind["google"]["detail"] + + oauth_connectors.save("google", 1, {"access_token": "at", "expires_at": 9_999_999_999, + "scope": "openid email drive"}) + rows = client.get("/api/agent/connectors").json()["connectors"] + g = {r["kind"]: r for r in rows if r["builtin"]}["google"] + assert g["status"] == "ok" and "connecté" in g["detail"] + + +def test_tool_dispatches_to_oauth_api(client, configured, monkeypatch): + from app.services.tool_registry import ToolRegistry + + oauth_connectors.save("google", 1, {"access_token": "at", "refresh_token": "", + "expires_at": int(time.time()) + 3600, + "scope": "openid"}) + + async def fake_get(url, headers=None): + assert url.startswith("https://www.googleapis.com/") + assert headers["Authorization"] == "Bearer at" + return 200, '{"name": "Bruno"}' + + monkeypatch.setattr(oauth_connectors, "_api_get", fake_get) + res = asyncio.run(ToolRegistry().execute( + "connector_fetch", {"connector": "google", "path": "/oauth2/v3/userinfo"}, + user_id=1)) + assert res.status == "success" + assert "Bruno" in res.data["text"] + + +def test_oauth_routes_require_session(client): + anon_csrf(client) + assert client.get("/api/agent/connectors/oauth/google/status").status_code == 401 + assert client.post("/api/agent/connectors/oauth/google/authorize").status_code == 401 + assert client.post("/api/agent/connectors/oauth/google/disconnect").status_code == 401 + # kind inconnu → 404 même authentifié + assert client.post("/api/agent/connectors/oauth/dropbox/authorize").status_code == 404 + + +def test_oauth_menu_wired(): + src = JS_PATH.read_text(encoding="utf-8") + for token in ("'connector-connect'", "'connector-disconnect'", + "connectorConnect()", "connectorDisconnect()", + "qs.get('oauth_error')", "Connecteur connecté."): + assert token in src, token + assert "c.oauth" in src + # le fournisseur revient avec `oauth=connected` (côté route) + router = (ROOT / "app" / "routers" / "agent.py").read_text(encoding="utf-8") + assert "oauth=connected" in router