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:
@@ -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):
|
||||
|
||||
@@ -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