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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user