Spaces:
Running
Running
| """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' | |
| 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) | |
| 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) | |
| 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}) | |
| 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}) | |