#!/usr/bin/env python3 """oauth_bootstrap.py — flusso OAuth ONE-TIME per ottenere il refresh_token gmail.readonly. Da eseguire UNA SOLA VOLTA, in modo interattivo, con Mauro davanti al browser: lui fa login/consenso come se stesso su maurof.gagliardi@gmail.com. Il risultato (refresh_token) NON viene scritto su disco da questo script: viene stampato su stdout e va copiato a mano nel file .env locale (gitignored) — vedi .env.example. PREREQUISITO (lato Google Cloud Console, fatto da Mauro una volta): 1. Console Google Cloud -> crea/usa un progetto. 2. "API e servizi" -> "Libreria" -> abilita "Gmail API". 3. "API e servizi" -> "Schermata consenso OAuth": - User type: External (va bene) -> pubblica in "Testing" con maurof.gagliardi@gmail.com tra gli utenti di test (in Testing il refresh_token dura ~7 giorni se l'app non e' verificata: valutare "In produzione" per un refresh_token stabile, oppure mettere in conto un re-consenso periodico). - Scope: aggiungere SOLO https://www.googleapis.com/auth/gmail.readonly 4. "Credenziali" -> "Crea credenziali" -> "ID client OAuth" -> tipo "App desktop". -> ottieni CLIENT_ID e CLIENT_SECRET. 5. Mettili nel .env locale come GMAIL_CHECK_CLIENT_ID / GMAIL_CHECK_CLIENT_SECRET (oppure passali qui con --client-id / --client-secret). USO python oauth_bootstrap.py # legge client_id/secret dal .env accanto python oauth_bootstrap.py --env PATH python oauth_bootstrap.py --client-id ... --client-secret ... COSA FA - Avvia un piccolo server locale su http://localhost: (redirect loopback, metodo raccomandato da Google per le app desktop; l'OOB copia-incolla e' deprecato). - Stampa l'URL di autorizzazione: Mauro lo apre, sceglie maurof.gagliardi@gmail.com, concede l'accesso in sola lettura. - Cattura il ?code= dal redirect, lo scambia per i token (access_type=offline + prompt=consent => arriva il refresh_token). - Stampa SOLO il refresh_token su stdout. Nessuna scrittura file. """ from __future__ import annotations import argparse import http.server import socket import sys import threading import urllib.parse import webbrowser from pathlib import Path import httpx AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" TOKEN_URL = "https://oauth2.googleapis.com/token" SCOPE = "https://www.googleapis.com/auth/gmail.readonly" def load_env(path: Path) -> dict[str, str]: if not path.is_file(): return {} out: dict[str, str] = {} for raw in path.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, val = line.partition("=") out[key.strip()] = val.strip().strip('"').strip("'") return out def free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] class _Catcher(http.server.BaseHTTPRequestHandler): code: str | None = None error: str | None = None def do_GET(self) -> None: # noqa: N802 (nome imposto da BaseHTTPRequestHandler) params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) _Catcher.code = (params.get("code") or [None])[0] _Catcher.error = (params.get("error") or [None])[0] self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.end_headers() msg = "OK, puoi chiudere questa scheda e tornare al terminale." if _Catcher.error: msg = f"Errore dal consenso: {_Catcher.error}" self.wfile.write(msg.encode("utf-8")) def log_message(self, *_args) -> None: # silenzia il logging del server pass def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Bootstrap OAuth one-time (gmail.readonly)") parser.add_argument( "--env", default=str(Path(__file__).resolve().parent / ".env"), help="file .env da cui leggere client_id/secret se non passati a riga di comando", ) parser.add_argument("--client-id", default=None) parser.add_argument("--client-secret", default=None) args = parser.parse_args(argv) env = load_env(Path(args.env)) client_id = args.client_id or env.get("GMAIL_CHECK_CLIENT_ID", "") client_secret = args.client_secret or env.get("GMAIL_CHECK_CLIENT_SECRET", "") if not client_id or not client_secret: print( "Servono client_id e client_secret: mettili nel .env come " "GMAIL_CHECK_CLIENT_ID / GMAIL_CHECK_CLIENT_SECRET, oppure passali con " "--client-id / --client-secret.", file=sys.stderr, ) return 2 port = free_port() redirect_uri = f"http://localhost:{port}" server = http.server.HTTPServer(("127.0.0.1", port), _Catcher) threading.Thread(target=server.serve_forever, daemon=True).start() auth_params = { "client_id": client_id, "redirect_uri": redirect_uri, "response_type": "code", "scope": SCOPE, "access_type": "offline", "prompt": "consent", } url = f"{AUTH_URL}?{urllib.parse.urlencode(auth_params)}" print("\nApri questo URL nel browser (login come maurof.gagliardi@gmail.com):\n") print(url + "\n") try: webbrowser.open(url) except Exception: # noqa: BLE001 (ambiente headless: nessun browser, non e' un errore) pass print("In attesa del redirect su", redirect_uri, "...\n") try: while _Catcher.code is None and _Catcher.error is None: pass # il server gira su un thread; qui aspettiamo il callback except KeyboardInterrupt: server.shutdown() return 1 server.shutdown() if _Catcher.error: print(f"Consenso negato / errore: {_Catcher.error}", file=sys.stderr) return 3 resp = httpx.post( TOKEN_URL, data={ "client_id": client_id, "client_secret": client_secret, "code": _Catcher.code, "grant_type": "authorization_code", "redirect_uri": redirect_uri, }, timeout=30.0, ) if resp.status_code != 200: print(f"Scambio code->token fallito: HTTP {resp.status_code} {resp.text[:300]}", file=sys.stderr) return 3 payload = resp.json() refresh_token = payload.get("refresh_token") if not refresh_token: print( "Nessun refresh_token nella risposta (gia' concesso in passato senza " "prompt=consent?). Revoca l'accesso da https://myaccount.google.com/permissions " "e riprova.", file=sys.stderr, ) return 3 print("\n=== REFRESH TOKEN (copialo nel .env come GMAIL_CHECK_REFRESH_TOKEN) ===") print(refresh_token) print("=== non committarlo, non scriverlo in file sincronizzati in chiaro ===\n") return 0 if __name__ == "__main__": sys.exit(main())