"""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(VAULT_KEY[:32] base64url-padded) — richiede cryptography>=42. """ import os, time, secrets, json, logging, asyncio from typing import Optional from fastapi import APIRouter, Request, HTTPException from fastapi.responses import RedirectResponse, JSONResponse import httpx _logger = logging.getLogger('api.auth_managed') router = APIRouter() # ── Fernet encryption setup ────────────────────────────────────────────────── def _get_fernet(): """Lazy-init Fernet cipher from VAULT_KEY. Returns None if not configured.""" try: from cryptography.fernet import Fernet import base64 vault_key = os.getenv('VAULT_KEY', '') if not vault_key: return None # Derive 32-byte key from VAULT_KEY (pad/truncate) → Fernet requires 32-byte urlsafe-b64 raw = (vault_key[:32] + '0' * 32)[:32].encode('utf-8') fernet_key = base64.urlsafe_b64encode(raw) return Fernet(fernet_key) except Exception as e: _logger.warning('[auth_managed] Fernet init failed: %s', e) return None def _encrypt(text: str) -> str: f = _get_fernet() if not f or not text: return text # fallback: plain (discouraged in prod) return f.encrypt(text.encode()).decode() def _decrypt(token: str) -> str: f = _get_fernet() if not f or not token: return token try: return f.decrypt(token.encode()).decode() except Exception: return '' # corrotto o key cambiata # ── In-memory OAuth state store (CSRF) ────────────────────────────────────── # {state_token: {'provider': str, 'user_id': str, 'created_at': float}} _oauth_states: dict[str, dict] = {} _STATE_TTL = 600 # 10 minuti def _make_state(provider: str, user_id: str) -> str: _purge_states() state = secrets.token_urlsafe(32) _oauth_states[state] = {'provider': provider, 'user_id': user_id, 'created_at': time.time()} return state def _consume_state(state: str) -> Optional[dict]: _purge_states() entry = _oauth_states.pop(state, None) if not entry: return None if time.time() - entry['created_at'] > _STATE_TTL: return None return entry def _purge_states(): now = time.time() expired = [k for k, v in _oauth_states.items() if now - v['created_at'] > _STATE_TTL] for k in expired: _oauth_states.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', 'scope': 'openid email profile https://www.googleapis.com/auth/calendar', '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') .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.""" row = await _sb_get_token(user_id, provider) if not row: return None return _decrypt(row['access_token']) # ── Routes ──────────────────────────────────────────────────────────────────── def _user_id(request: Request) -> str: return request.headers.get('X-User-ID', '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 = _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 = _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): """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): """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})