Terminal / api /auth_managed.py
Baida-A's picture
sync: 125 file da Baida98/AI@8374b07e (2026-07-10 21:36 UTC)
e5220dc verified
Raw
History Blame
22.9 kB
"""backend/api/auth_managed.py — OAuth "One-Click" connectors (P38).
Rotte:
GET /api/auth/connect/{provider} → redirect URL OAuth del provider
GET /api/auth/callback/{provider} → scambia code → token, salva su Supabase cifrato
GET /api/auth/providers → stato connessione tutti i provider per l'utente
DELETE /api/auth/disconnect/{provider} → revoca e cancella token da Supabase
Provider supportati: github, google, instagram.
I CLIENT_ID/SECRET vanno nei secret HF Spaces (mai nel codice).
Cifratura token: Fernet(PBKDF2HMAC-SHA256, 260k iter, salt fisso) — richiede cryptography>=42.
"""
import os, time, secrets, json, logging, asyncio
from typing import Optional
from fastapi import APIRouter, Depends, Request, HTTPException
from .auth_guard import require_role, AuthRole
from fastapi.responses import RedirectResponse, JSONResponse
import httpx
_logger = logging.getLogger('api.auth_managed')
router = APIRouter()
# ── Fernet encryption setup ──────────────────────────────────────────────────
# Salt statico pubblico: accettabile per chiave macchina (non password utente).
# Il VAULT_KEY è il segreto; il salt previene rainbow-table cross-application.
# ⚠️ ATTENZIONE MIGRAZIONE: cambiare questo salt o la derivazione invalida
# tutti i token OAuth cifrati esistenti → gli utenti dovranno riconnettere
# i provider. Questo è intenzionale quando si corregge una derivazione debole.
_FERNET_SALT = b'agente-ai-vault-v1-pbkdf2'
def _get_fernet():
"""Lazy-init Fernet cipher da VAULT_KEY via PBKDF2HMAC-SHA256 (260k iter).
Fix P19-SEC2-4: SHA-256 raw (veloce, GPU-bruteforce in ore su chiavi brevi)
sostituito con PBKDF2HMAC 260_000 iter — conforme NIST SP 800-132 (2023).
⚠️ Cambio di derivazione: i token cifrati con SHA-256 non sono più decifrabili.
_decrypt() ritorna '' su InvalidToken — gli utenti devono riconnettersi.
"""
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes as _hashes
import base64
vault_key = os.getenv('VAULT_KEY', '')
if not vault_key:
raise RuntimeError('VAULT_KEY non configurata — impossibile cifrare/decifrare i token OAuth')
kdf = PBKDF2HMAC(
algorithm=_hashes.SHA256(),
length=32,
salt=_FERNET_SALT,
iterations=260_000, # NIST SP 800-132 (2023): ≥ 210_000 per PBKDF2-SHA256
)
fernet_key = base64.urlsafe_b64encode(kdf.derive(vault_key.encode('utf-8')))
return Fernet(fernet_key)
def _encrypt(text: str) -> str:
if not text:
return text
f = _get_fernet() # P19-SEC2-4: propaga eccezione — niente più plaintext fallback
return f.encrypt(text.encode()).decode()
def _decrypt(token: str) -> str:
if not token:
return token
f = _get_fernet()
try:
return f.decrypt(token.encode()).decode()
except Exception:
_logger.error('[auth_managed] decrypt fallito — token corrotto o VAULT_KEY cambiata')
return '' # corrotto o key cambiata
# ── OAuth state store (CSRF) — P19-SEC2-5 ────────────────────────────────────
# Era in-memory dict: rotto con >1 worker/replica (state creato su worker A,
# consumato su worker B → 404/invalid_state) e perso a ogni restart.
# Ora persistito su Supabase tabella `oauth_states` (vedi sec01_rls_agent_tasks.sql).
_STATE_TTL = 600 # 10 minuti
# Fallback in-memory SOLO per sviluppo locale senza Supabase configurato.
_oauth_states_fallback: dict[str, dict] = {}
async def _make_state(provider: str, user_id: str) -> str:
from api.state import _sb
state = secrets.token_urlsafe(32)
now = time.time()
if _sb:
try:
await asyncio.to_thread(
lambda: _sb.table('oauth_states').insert({
'state': state,
'provider': provider,
'user_id': user_id,
# created_at: lasciato al DEFAULT NOW() della tabella (TIMESTAMPTZ)
}).execute()
)
return state
except Exception as e:
_logger.warning('[auth_managed] oauth_states insert fallito, fallback memoria: %s', e)
# SEC2-7: in produzione (Railway/HF Spaces) il fallback in-memory è pericoloso
# su multi-replica: state creato su replica A, consumato su replica B → CSRF bypass.
# In dev locale (_sb assente o Supabase non configurato) il fallback rimane attivo.
if os.getenv('RAILWAY_ENVIRONMENT') or os.getenv('SPACE_ID'):
raise HTTPException(503, detail={
'error': 'oauth_state_store_unavailable',
'detail': 'Il database OAuth (Supabase) non è raggiungibile. Riprova tra qualche secondo.',
})
_purge_states_fallback()
_oauth_states_fallback[state] = {'provider': provider, 'user_id': user_id, 'created_at': now}
return state
async def _consume_state(state: str) -> Optional[dict]:
from api.state import _sb
if _sb:
try:
res = await asyncio.to_thread(
lambda: _sb.table('oauth_states').select('*').eq('state', state).limit(1).execute()
)
if res.data:
row = res.data[0]
await asyncio.to_thread(
lambda: _sb.table('oauth_states').delete().eq('state', state).execute()
)
# created_at è TIMESTAMPTZ (stringa ISO8601) — parse per calcolare l'età
try:
from datetime import datetime, timezone
created_raw = row.get('created_at', '')
created_dt = datetime.fromisoformat(created_raw.replace('Z', '+00:00'))
age_s = (datetime.now(timezone.utc) - created_dt).total_seconds()
except Exception:
age_s = 0 # se il parsing fallisce, non blocchiamo il flow OAuth per questo
if age_s > _STATE_TTL:
return None
return {'provider': row['provider'], 'user_id': row['user_id']}
return None
except Exception as e:
_logger.warning('[auth_managed] oauth_states select fallito, fallback memoria: %s', e)
# SEC2-7: in produzione, se Supabase è irraggiungibile rifiutiamo il state
# (sicuro: l'utente deve ripetere il flow OAuth). Meglio un 400 che un bypass CSRF.
if os.getenv('RAILWAY_ENVIRONMENT') or os.getenv('SPACE_ID'):
return None
_purge_states_fallback()
entry = _oauth_states_fallback.pop(state, None)
if not entry:
return None
if time.time() - entry['created_at'] > _STATE_TTL:
return None
return entry
def _purge_states_fallback():
now = time.time()
expired = [k for k, v in _oauth_states_fallback.items() if now - v['created_at'] > _STATE_TTL]
for k in expired:
_oauth_states_fallback.pop(k, None)
# ── Provider configs ──────────────────────────────────────────────────────────
_BACKEND_URL = os.getenv('BACKEND_URL', '').rstrip('/')
def _get_callback_url(provider: str) -> str:
return f"{_BACKEND_URL}/api/auth/callback/{provider}"
def _get_frontend_url() -> str:
"""URL frontend da redirigere dopo il callback OAuth."""
return os.getenv('FRONTEND_URL', 'https://agente-ai.pages.dev')
_PROVIDER_CONFIGS = {
'github': {
'authorize_url': 'https://github.com/login/oauth/authorize',
'token_url': 'https://github.com/login/oauth/access_token',
'userinfo_url': 'https://api.github.com/user',
'scope': 'read:user,repo',
'client_id_env': 'GITHUB_OAUTH_CLIENT_ID',
'client_secret_env': 'GITHUB_OAUTH_CLIENT_SECRET',
},
'google': {
'authorize_url': 'https://accounts.google.com/o/oauth2/v2/auth',
'token_url': 'https://oauth2.googleapis.com/token',
'userinfo_url': 'https://www.googleapis.com/oauth2/v2/userinfo',
# P19-SEC2-6: rimosso scope 'calendar' (accesso full R/W al calendario) —
# non richiesto da nessuna feature attuale, violava il principio del minimo privilegio.
'scope': 'openid email profile',
'client_id_env': 'GOOGLE_OAUTH_CLIENT_ID',
'client_secret_env': 'GOOGLE_OAUTH_CLIENT_SECRET',
},
'instagram': {
'authorize_url': 'https://api.instagram.com/oauth/authorize',
'token_url': 'https://api.instagram.com/oauth/access_token',
'userinfo_url': 'https://graph.instagram.com/me?fields=id,username',
'scope': 'user_profile,user_media',
'client_id_env': 'INSTAGRAM_CLIENT_ID',
'client_secret_env': 'INSTAGRAM_CLIENT_SECRET',
},
}
# ── Supabase helpers ──────────────────────────────────────────────────────────
async def _sb_upsert_token(user_id: str, provider: str, access_token: str,
refresh_token: str, expires_at: int, scope: str, meta: dict) -> None:
from api.state import _sb
if not _sb:
return
now = int(time.time() * 1000)
payload = {
'user_id': user_id,
'provider': provider,
'access_token': _encrypt(access_token),
'refresh_token': _encrypt(refresh_token) if refresh_token else '',
'expires_at': expires_at,
'scope': scope,
'raw_meta': json.dumps(meta)[:4000],
'created_at': now,
'updated_at': now,
}
try:
await asyncio.to_thread(
lambda: _sb.table('managed_tokens')
.upsert(payload, on_conflict='user_id,provider')
.execute()
)
except Exception as e:
_logger.warning('[auth_managed] upsert token %s/%s: %s', user_id, provider, e)
async def _sb_get_token(user_id: str, provider: str) -> Optional[dict]:
from api.state import _sb
if not _sb:
return None
try:
res = await asyncio.to_thread(
lambda: _sb.table('managed_tokens')
.select('provider,scope,expires_at,updated_at,raw_meta,access_token,refresh_token')
.eq('user_id', user_id)
.eq('provider', provider)
.limit(1)
.execute()
)
return res.data[0] if res.data else None
except Exception as e:
_logger.debug('[auth_managed] get_token %s/%s: %s', user_id, provider, e)
return None
async def _sb_list_tokens(user_id: str) -> list[dict]:
from api.state import _sb
if not _sb:
return []
try:
res = await asyncio.to_thread(
lambda: _sb.table('managed_tokens')
.select('provider,scope,expires_at,updated_at,raw_meta')
.eq('user_id', user_id)
.execute()
)
return res.data or []
except Exception as e:
_logger.debug('[auth_managed] list_tokens %s: %s', user_id, e)
return []
async def _sb_delete_token(user_id: str, provider: str) -> None:
from api.state import _sb
if not _sb:
return
try:
await asyncio.to_thread(
lambda: _sb.table('managed_tokens')
.delete()
.eq('user_id', user_id)
.eq('provider', provider)
.execute()
)
except Exception as e:
_logger.warning('[auth_managed] delete_token %s/%s: %s', user_id, provider, e)
# Public helper: altri moduli chiamano questa per ottenere un token decifrato
async def get_managed_token(user_id: str, provider: str) -> Optional[str]:
"""Restituisce il token d'accesso decifrato per (user_id, provider). None se non connesso.
P19-SEC2-8: prima ritornava sempre il token salvato, anche se scaduto da
tempo (provider come Google li invalidano dopo ~1h) → chiamate a valle
fallivano silenziosamente con 401. Ora, se scaduto e c'è un refresh_token,
tenta il refresh presso il provider prima di restituire.
"""
row = await _sb_get_token(user_id, provider)
if not row:
return None
exp = row.get('expires_at', 0)
now_ms = int(time.time() * 1000)
if exp and exp > now_ms + 60_000: # ancora valido per >60s
return _decrypt(row['access_token'])
encrypted_refresh = row.get('refresh_token', '')
if not encrypted_refresh:
# Niente refresh_token: ritorna quello che c'è (potrebbe essere già scaduto)
return _decrypt(row['access_token'])
cfg = _PROVIDER_CONFIGS.get(provider)
if not cfg:
return _decrypt(row['access_token'])
refresh_token = _decrypt(encrypted_refresh)
if not refresh_token:
return _decrypt(row['access_token'])
client_id = os.getenv(cfg['client_id_env'], '')
client_secret = os.getenv(cfg['client_secret_env'], '')
try:
async with httpx.AsyncClient(timeout=15) as http:
resp = await http.post(
cfg['token_url'],
data={
'grant_type': 'refresh_token',
'refresh_token': refresh_token,
'client_id': client_id,
'client_secret': client_secret,
},
headers={'Accept': 'application/json'},
)
if resp.status_code != 200:
_logger.warning('[auth_managed] refresh token fallito %s/%s: %s', user_id, provider, resp.text[:200])
return _decrypt(row['access_token'])
tok_json = resp.json()
new_access = tok_json.get('access_token', '')
new_refresh = tok_json.get('refresh_token', refresh_token) # alcuni provider non lo riemettono
expires_in = tok_json.get('expires_in', 0)
new_expires_at = int((time.time() + expires_in) * 1000) if expires_in else 0
if not new_access:
return _decrypt(row['access_token'])
await _sb_upsert_token(user_id, provider, new_access, new_refresh,
new_expires_at, row.get('scope', cfg['scope']),
json.loads(row.get('raw_meta') or '{}') if isinstance(row.get('raw_meta'), str) else {})
_logger.info('[auth_managed] token refreshed %s/%s (expires_at=%d)', user_id, provider, new_expires_at)
return new_access
except Exception as e:
_logger.error('[auth_managed] refresh exception %s/%s: %s', user_id, provider, e)
return _decrypt(row['access_token'])
# ── Routes ────────────────────────────────────────────────────────────────────
def _user_id(request: Request) -> str:
# P19-SEC2-7: NON fidarsi di X-User-ID lato client — permetteva a chiunque di
# impersonare/leggere i token OAuth di un altro utente semplicemente inviando
# un header diverso. L'app è single-tenant (un solo utente reale), quindi si
# usa sempre 'default'. Se in futuro serve multi-tenant, l'identità va
# derivata da una sessione autenticata server-side (JWT/cookie firmato),
# mai da un header controllato dal client.
return 'default'
@router.get('/api/auth/connect/{provider}')
async def connect_provider(provider: str, request: Request):
"""Genera l'URL OAuth e redirige il browser dell'utente."""
cfg = _PROVIDER_CONFIGS.get(provider)
if not cfg:
raise HTTPException(400, detail={'error': 'unknown_provider', 'provider': provider})
client_id = os.getenv(cfg['client_id_env'], '')
if not client_id:
raise HTTPException(503, detail={
'error': 'provider_not_configured',
'provider': provider,
'hint': f"Set {cfg['client_id_env']} in HuggingFace Spaces secrets.",
})
user_id = _user_id(request)
state = await _make_state(provider, user_id)
callback = _get_callback_url(provider)
params = {
'client_id': client_id,
'redirect_uri': callback,
'scope': cfg['scope'],
'state': state,
'response_type': 'code',
}
# Google richiede access_type=offline per il refresh_token
if provider == 'google':
params['access_type'] = 'offline'
params['prompt'] = 'consent'
from urllib.parse import urlencode
auth_url = cfg['authorize_url'] + '?' + urlencode(params)
_logger.info('[auth_managed] connect %s → redirect %s', provider, auth_url[:80])
return RedirectResponse(url=auth_url, status_code=302)
@router.get('/api/auth/callback/{provider}')
async def oauth_callback(provider: str, request: Request):
"""Riceve il code dal provider, scambia con token, salva su Supabase."""
cfg = _PROVIDER_CONFIGS.get(provider)
if not cfg:
raise HTTPException(400, detail={'error': 'unknown_provider'})
code = request.query_params.get('code', '')
state = request.query_params.get('state', '')
error = request.query_params.get('error', '')
frontend = _get_frontend_url()
if error:
_logger.warning('[auth_managed] callback %s error=%s', provider, error)
return RedirectResponse(url=f"{frontend}?oauth_error={error}&provider={provider}")
state_data = await _consume_state(state)
if not state_data:
_logger.warning('[auth_managed] invalid/expired state %s', state[:20])
return RedirectResponse(url=f"{frontend}?oauth_error=invalid_state&provider={provider}")
user_id = state_data['user_id']
client_id = os.getenv(cfg['client_id_env'], '')
client_secret = os.getenv(cfg['client_secret_env'], '')
callback_url = _get_callback_url(provider)
# Scambia code → token
try:
async with httpx.AsyncClient(timeout=15) as http:
token_resp = await http.post(
cfg['token_url'],
data={
'code': code,
'client_id': client_id,
'client_secret': client_secret,
'redirect_uri': callback_url,
'grant_type': 'authorization_code',
},
headers={'Accept': 'application/json'},
)
if token_resp.status_code != 200:
_logger.error('[auth_managed] token exchange %s: %s', provider, token_resp.text[:200])
return RedirectResponse(url=f"{frontend}?oauth_error=token_exchange&provider={provider}")
tok_json = token_resp.json()
access_token = tok_json.get('access_token', '')
refresh_token = tok_json.get('refresh_token', '')
scope = tok_json.get('scope', cfg['scope'])
expires_in = tok_json.get('expires_in', 0)
expires_at = int((time.time() + expires_in) * 1000) if expires_in else 0
if not access_token:
_logger.error('[auth_managed] no access_token from %s: %s', provider, tok_json)
return RedirectResponse(url=f"{frontend}?oauth_error=no_token&provider={provider}")
# Recupera metadata utente (opzionale, soft fail)
meta: dict = {}
try:
async with httpx.AsyncClient(timeout=8) as http:
me_resp = await http.get(
cfg['userinfo_url'],
headers={'Authorization': f'Bearer {access_token}', 'Accept': 'application/json'},
)
if me_resp.status_code == 200:
meta = me_resp.json()
except Exception:
pass
await _sb_upsert_token(user_id, provider, access_token, refresh_token,
expires_at, str(scope), meta)
_logger.info('[auth_managed] token saved %s/%s (expires_at=%d)', user_id, provider, expires_at)
except Exception as e:
_logger.error('[auth_managed] callback exception %s: %s', provider, e)
return RedirectResponse(url=f"{frontend}?oauth_error=server_error&provider={provider}")
# Redirect frontend con successo
return RedirectResponse(url=f"{frontend}?oauth_success=1&provider={provider}", status_code=302)
@router.get('/api/auth/providers')
async def list_providers(request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix: info-disclosure
"""Restituisce stato connessione di tutti i provider per l'utente corrente."""
user_id = _user_id(request)
rows = await _sb_list_tokens(user_id)
connected = {r['provider']: r for r in rows}
now_ms = int(time.time() * 1000)
result = []
for pname, cfg in _PROVIDER_CONFIGS.items():
row = connected.get(pname)
is_configured = bool(os.getenv(cfg['client_id_env'], ''))
if row:
exp = row.get('expires_at', 0)
meta_raw = row.get('raw_meta', '{}')
try:
meta = json.loads(meta_raw) if isinstance(meta_raw, str) else meta_raw
except Exception:
meta = {}
result.append({
'provider': pname,
'connected': True,
'configured': is_configured,
'expired': bool(exp and exp < now_ms),
'scope': row.get('scope', ''),
'updated_at': row.get('updated_at', 0),
'username': meta.get('login') or meta.get('email') or meta.get('username', ''),
})
else:
result.append({
'provider': pname,
'connected': False,
'configured': is_configured,
'expired': False,
'scope': '',
'updated_at': 0,
'username': '',
})
return JSONResponse({'providers': result, 'user_id': user_id})
@router.delete('/api/auth/disconnect/{provider}')
async def disconnect_provider(provider: str, request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
"""Rimuove il token salvato per il provider specificato."""
if provider not in _PROVIDER_CONFIGS:
raise HTTPException(400, detail={'error': 'unknown_provider'})
user_id = _user_id(request)
await _sb_delete_token(user_id, provider)
_logger.info('[auth_managed] disconnected %s/%s', user_id, provider)
return JSONResponse({'ok': True, 'provider': provider})