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:
@@ -20,6 +20,16 @@ GITHUB_OAUTH_CLIENT_SECRET=
|
|||||||
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
|
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
|
||||||
OAUTH_REDIRECT_URI=
|
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 ──
|
||||||
APP_SECRET_KEY=change-me-to-random
|
APP_SECRET_KEY=change-me-to-random
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
|
|||||||
@@ -1,5 +1,50 @@
|
|||||||
# Changelog - FlowDeck
|
# 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)
|
## v7.55.0 (2026-10-06) — Menu + : Connecteurs — socle (phase 5/8)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+26
-9
@@ -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)
|
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
|
||||||
> un menu à sections, tel que demandé :
|
> 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é
|
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.55.0**, OpenAPI régénéré
|
||||||
(519 chemins), CHANGELOG, ce ROADMAP
|
(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,
|
- [x] **OAuth2 PKCE Google** — Drive / Gmail / Calendar **en lecture seule** ;
|
||||||
lecture limitée aux sources choisies
|
connexion (`begin()` → URL + state + `code_verifier` S256), échange du code,
|
||||||
- [ ] **OAuth2 Microsoft (Graph)** — Outlook / OneDrive / SharePoint
|
**refresh automatique** (60 s de marge, `refresh_token` conservé si absent de
|
||||||
- [ ] **Écran connecteur** — état, scopes, dernière synchro, bouton déconnecter
|
la réponse), déconnexion — table `connector_tokens` (migration **33**,
|
||||||
- [ ] **Tests** — callbacks, refresh, échec, **aucun appel réseau réel** (transport injecté)
|
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
|
### 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
|
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 6 livrées le
|
||||||
2026-10-06 (v7.51.0 → v7.55.0)**, phases 6-8 à valider une par une avant « go ».
|
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.*
|
Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -126,6 +126,14 @@ class Settings(BaseSettings):
|
|||||||
agent_max_tokens_budget: int = 500000
|
agent_max_tokens_budget: int = 500000
|
||||||
agent_run_timeout_seconds: int = 300
|
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) ──
|
# ── Mémoire de l'agent (v7.54.0) ──
|
||||||
# État initial du toggle « Mémoire » à la création d'une conversation ;
|
# État initial du toggle « Mémoire » à la création d'une conversation ;
|
||||||
# le basculement se fait ensuite par PATCH /api/agent/conversations/{id}.
|
# le basculement se fait ensuite par PATCH /api/agent/conversations/{id}.
|
||||||
|
|||||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.55.0",
|
version="7.56.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -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)")
|
@register(32, "v7.55.0: connecteurs de l'agent (socle)")
|
||||||
def _migration_agent_connectors(conn: sqlite3.Connection) -> None:
|
def _migration_agent_connectors(conn: sqlite3.Connection) -> None:
|
||||||
"""Catalogue de connecteurs : 3 natifs servis à la volée + personnels ici.
|
"""Catalogue de connecteurs : 3 natifs servis à la volée + personnels ici.
|
||||||
|
|||||||
+114
-7
@@ -7,15 +7,17 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, HTTPException, Request
|
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.auth.session import get_current_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_conn
|
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.agent_engine import AgentEngine, undo_action
|
||||||
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
|
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
|
||||||
from app.services.llm_config import (
|
from app.services.llm_config import (
|
||||||
@@ -640,9 +642,9 @@ def delete_skill(request: Request, skill_id: int):
|
|||||||
|
|
||||||
@router.get("/connectors")
|
@router.get("/connectors")
|
||||||
def list_connectors_route(request: Request):
|
def list_connectors_route(request: Request):
|
||||||
"""Catalogue : 3 natifs (statut dérivé de la config) + personnels."""
|
"""Catalogue : natifs (statut dérivé de la config) + personnels."""
|
||||||
_current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
return {"connectors": connectors.list_connectors()}
|
return {"connectors": connectors.list_connectors(user_id)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/connectors")
|
@router.post("/connectors")
|
||||||
@@ -685,16 +687,121 @@ def delete_connectors_route(request: Request, connector_id: int):
|
|||||||
@router.post("/connectors/probe")
|
@router.post("/connectors/probe")
|
||||||
async def probe_connectors_route(request: Request, body: dict = Body(default={})):
|
async def probe_connectors_route(request: Request, body: dict = Body(default={})):
|
||||||
"""Teste un connecteur (id, nom ou kind natif) et persiste le résultat."""
|
"""Teste un connecteur (id, nom ou kind natif) et persiste le résultat."""
|
||||||
_current_user_id(request)
|
|
||||||
target = str(body.get("connector") or "").strip()
|
target = str(body.get("connector") or "").strip()
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=400, detail="connector est requis")
|
raise HTTPException(status_code=400, detail="connector est requis")
|
||||||
|
user_id = _current_user_id(request)
|
||||||
try:
|
try:
|
||||||
return await connectors.probe(target)
|
return await connectors.probe(target, user_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from 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 👍 / 👎) ──
|
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+31
-10
@@ -14,18 +14,30 @@ import logging
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
from app.services import oauth_connectors
|
||||||
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
|
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
|
||||||
|
|
||||||
|
|
||||||
# ── Natifs (config, sans réseau) ─────────────────────────────────────────────
|
# ── 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."""
|
"""(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":
|
if kind == "gitea":
|
||||||
ok = bool(settings.gitea_url) and settings.gitea_token not in ("", "change-me")
|
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é")
|
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'}")
|
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
|
||||||
|
|
||||||
|
|
||||||
def list_connectors() -> list[dict]:
|
def list_connectors(user_id: int | None = None) -> list[dict]:
|
||||||
"""Catalogue : 3 natifs (toujours présents) + les connecteurs personnels."""
|
"""Catalogue : natifs (toujours présents) + connecteurs personnels."""
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
for kind in NATIVE_KINDS:
|
for kind in NATIVE_KINDS:
|
||||||
status, detail = native_state(kind)
|
status, detail = native_state(kind, user_id)
|
||||||
out.append({
|
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,
|
"url": "", "enabled": True, "status": status, "detail": detail,
|
||||||
"has_secret": False,
|
"has_secret": False, "oauth": kind in OAUTH_KINDS,
|
||||||
})
|
})
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -183,11 +197,15 @@ def _resolve(connector: str) -> tuple[str, str | int]:
|
|||||||
raise ValueError(f"Connecteur inconnu: {token}")
|
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)."""
|
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
|
||||||
kind, ref = _resolve(connector)
|
kind, ref = _resolve(connector)
|
||||||
try:
|
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",
|
code, _ = await _get(f"{settings.gitea_url.rstrip('/')}/api/v1/version",
|
||||||
{"Authorization": f"token {settings.gitea_token}"})
|
{"Authorization": f"token {settings.gitea_token}"})
|
||||||
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
||||||
@@ -221,10 +239,13 @@ def _gh_headers() -> dict:
|
|||||||
return headers
|
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."""
|
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
|
||||||
kind, ref = _resolve(connector)
|
kind, ref = _resolve(connector)
|
||||||
path = (path or "").strip()
|
path = (path or "").strip()
|
||||||
|
if kind in OAUTH_KINDS:
|
||||||
|
return await oauth_connectors.api_get(kind, user_id, path)
|
||||||
if kind == "gitea":
|
if kind == "gitea":
|
||||||
base = settings.gitea_url.rstrip("/")
|
base = settings.gitea_url.rstrip("/")
|
||||||
url = f"{base}/{path.lstrip('/')}" if path else f"{base}/api/v1/version"
|
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("connector") or ""),
|
||||||
str(args.get("path") or ""),
|
str(args.get("path") or ""),
|
||||||
str(args.get("query") or ""),
|
str(args.get("query") or ""),
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# V74 — Menu + de l'assistant : hub de contexte (design)
|
# 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)** ;
|
> **Statut** : design — **phases 1 à 6 livrées (v7.51.0 → v7.56.0, 2026-10-06)** ;
|
||||||
> phases 6-8 restent à livrer. La **mémoire** a été simplifiée par rapport au
|
> 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
|
> 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
|
> `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
|
> utilise `GET /api/nav/menu?parent_id=` (un niveau par appel, contrat
|
||||||
|
|||||||
+195
-2
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "FlowDeck",
|
"title": "FlowDeck",
|
||||||
"version": "7.55.0"
|
"version": "7.56.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/auth/register": {
|
"/auth/register": {
|
||||||
@@ -16246,7 +16246,7 @@
|
|||||||
"agent"
|
"agent"
|
||||||
],
|
],
|
||||||
"summary": "List Connectors Route",
|
"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",
|
"operationId": "list_connectors_route_api_agent_connectors_get",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"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": {
|
"/api/agent/mentions": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -86,6 +86,18 @@
|
|||||||
init(){
|
init(){
|
||||||
var self = this;
|
var self = this;
|
||||||
var el = document.getElementById('fd-agent-panel');
|
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(){
|
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'); } }
|
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',
|
{key:'cn-probe', icon:'↻', label:'Tester le connecteur',
|
||||||
sub:'Appel de contrôle — status + détail', action:'connector-probe'}
|
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){
|
if(!c.builtin){
|
||||||
items.push({key:'cn-toggle', icon: c.enabled ? '⏸' : '▶',
|
items.push({key:'cn-toggle', icon: c.enabled ? '⏸' : '▶',
|
||||||
label: c.enabled ? 'Désactiver' : 'Activer',
|
label: c.enabled ? 'Désactiver' : 'Activer',
|
||||||
@@ -1319,6 +1339,30 @@
|
|||||||
self.fetchConnectors();
|
self.fetchConnectors();
|
||||||
}).catch(function(){ self.toast('Bascule impossible (réseau).', true); });
|
}).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(){
|
connectorDelete(){
|
||||||
var self = this, c = this.connector;
|
var self = this, c = this.connector;
|
||||||
if(!c || c.builtin || c.id == null) return;
|
if(!c || c.builtin || c.id == null) return;
|
||||||
@@ -1520,6 +1564,8 @@
|
|||||||
if(it.action === 'connector-probe'){ this.connectorProbe(); return; }
|
if(it.action === 'connector-probe'){ this.connectorProbe(); return; }
|
||||||
if(it.action === 'connector-toggle'){ this.connectorToggle(); return; }
|
if(it.action === 'connector-toggle'){ this.connectorToggle(); return; }
|
||||||
if(it.action === 'connector-delete'){ this.connectorDelete(); 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) ──
|
// ── Design System – Canevas (v7.53.0) ──
|
||||||
if(it.action === 'canvases'){
|
if(it.action === 'canvases'){
|
||||||
this.fetchCanvases(); this.plusSection = 'canvases'; this.plusFocus = 0; return;
|
this.fetchCanvases(); this.plusSection = 'canvases'; this.plusFocus = 0; return;
|
||||||
|
|||||||
@@ -27,12 +27,15 @@ def _create(client, **kw):
|
|||||||
def test_native_connectors_always_listed(client):
|
def test_native_connectors_always_listed(client):
|
||||||
rows = client.get("/api/agent/connectors").json()["connectors"]
|
rows = client.get("/api/agent/connectors").json()["connectors"]
|
||||||
native = {r["kind"]: r for r in rows if r["builtin"]}
|
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():
|
for r in native.values():
|
||||||
assert r["id"] is None
|
assert r["id"] is None
|
||||||
assert r["status"] in ("ok", "missing")
|
assert r["status"] in ("ok", "missing")
|
||||||
assert r["enabled"] is True
|
assert r["enabled"] is True
|
||||||
assert native["web"]["status"] == "ok"
|
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):
|
def test_create_custom_connector_never_returns_secret(client):
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user