Auth autonome pur HTTP via /gw/refresh (sans navigateur)

Le refresh du token passe désormais par POST /gw/refresh (l'endpoint que la
SPA appelle) au lieu d'un navigateur headless : pur httpx, refresh_token rotaté
persisté dans token.json, fenêtre 60j remise à zéro à chaque refresh. Lock
single-flight pour la rotation. get_token()/auth_status() tentent /gw/refresh
avant le filet Playwright. Homelab allumé = authentifié indéfiniment, sans re-sync.
This commit is contained in:
jerem
2026-06-18 12:06:42 +02:00
parent e37a27cc1a
commit e7776a539e
3 changed files with 121 additions and 10 deletions

View File

@@ -16,6 +16,7 @@ from __future__ import annotations
import base64
import json
import os
import threading
import time
import urllib.parse
from pathlib import Path
@@ -30,6 +31,20 @@ TOKEN_CACHE = SESSION_DIR / "token.json"
STATE_PATH = SESSION_DIR / "storage_state.json"
BASE_URL = "https://www.hellofresh.fr"
# Passerelle de renouvellement de HelloFresh : `POST {BASE_URL}/gw/refresh` avec
# `{"refresh_token": ...}` renvoie un bundle frais (access_token JWT + nouveau
# refresh_token rotaté + refresh_expires_in ~60 j). C'est ce que la SPA appelle ;
# côté serveur HelloFresh c'est wrappé sur Auth0 (le client_secret reste chez eux,
# d'où l'impossibilité d'appeler Auth0 en direct). Pur HTTP, pas de navigateur,
# hors du parcours web protégé par l'anti-bot. Chaque refresh remet la fenêtre à
# 60 j : un homelab allumé reste authentifié indéfiniment.
REFRESH_URL = f"{BASE_URL}/gw/refresh"
# Sérialise les renouvellements : les outils MCP tournent dans un worker thread
# (anyio.to_thread) → appels concurrents possibles. La rotation du refresh_token
# rendrait fragile un double appel ; le lock sérialise (avec re-test du cache frais
# après acquisition).
_refresh_lock = threading.Lock()
# Page qui déclenche des appels gateway authentifiés (menu de la semaine).
MENU_PAGE = f"{BASE_URL}/my-account/deliveries/menu"
ACCOUNT_PAGE = f"{BASE_URL}/my-account"
@@ -274,12 +289,88 @@ def token_from_storage_state() -> str | None:
return None
def _save_token(access_token: str, refresh_token: str | None = None,
gateways: list[str] | None = None) -> dict:
"""Met à jour `.session/token.json` (access token + refresh_token rotatif).
On préserve les `gateways`/`refresh_token` déjà connus si non fournis, pour ne
pas perdre le refresh_token courant lors d'un simple rafraîchissement d'access.
"""
prev = _read_token_cache() or {}
result = {
"token": access_token,
"gateways": gateways if gateways is not None else prev.get("gateways", [f"{BASE_URL}/gw"]),
"captured_at": time.time(),
}
rt = refresh_token or prev.get("refresh_token")
if rt:
result["refresh_token"] = rt
SESSION_DIR.mkdir(parents=True, exist_ok=True)
TOKEN_CACHE.write_text(json.dumps(result, indent=2), encoding="utf-8")
return result
def _refresh_token_from_storage_state() -> str | None:
"""Extrait le refresh_token du cookie `apiV2Auth` de storage_state.json.
Sert de bootstrap : c'est le refresh_token déjà présent dans la session capturée,
donc aucun nouveau login local n'est requis pour migrer vers le refresh HTTP.
"""
if not STATE_PATH.exists():
return None
try:
state = json.loads(STATE_PATH.read_text(encoding="utf-8"))
for c in state.get("cookies", []):
if c.get("name") == "apiV2Auth":
data = json.loads(urllib.parse.unquote(c["value"]))
return data.get("refresh_token") or None
except Exception:
return None
return None
def _get_refresh_token() -> str | None:
"""Refresh token courant : cache token.json (rotations) puis bootstrap cookie."""
cached = _read_token_cache()
if cached and cached.get("refresh_token"):
return cached["refresh_token"]
return _refresh_token_from_storage_state()
def _refresh_session() -> str | None:
"""Renouvelle la session via `POST /gw/refresh` (pur HTTP, sans navigateur).
Persiste le bundle frais : access_token + nouveau refresh_token (rotaté). Renvoie
l'access token, ou None si pas de refresh_token disponible / échec réseau ou HTTP.
"""
rt = _get_refresh_token()
if not rt:
return None
try:
resp = httpx.post(REFRESH_URL, json={"refresh_token": rt}, timeout=20.0,
headers={"Accept": "application/json"})
except Exception as e:
print(f"[auth] /gw/refresh réseau KO: {e}")
return None
if resp.status_code != 200:
print(f"[auth] /gw/refresh → HTTP {resp.status_code} ({resp.text[:160]})")
return None
data = resp.json()
access = data.get("access_token")
if not access:
return None
_save_token(access, refresh_token=data.get("refresh_token"))
return access
def get_token(force: bool = False) -> str:
"""Renvoie un bearer token valide, par ordre de préférence :
1. token en cache encore frais ;
2. cookie `apiV2Auth` de la session synchronisée (sans navigateur) ;
3. capture via navigateur (login si besoin) — impossible en headless si session morte.
3. refresh via `/gw/refresh` (pur HTTP, sans navigateur) ;
4. capture via navigateur (filet de secours : auto-login HF_EMAIL/HF_PASSWORD
ou login interactif) — impossible en headless si la session est morte.
"""
if not force:
cached = _read_token_cache()
@@ -288,8 +379,17 @@ def get_token(force: bool = False) -> str:
tok = token_from_storage_state()
if tok:
return tok
# Refresh nécessaire : si on a une session synchronisée, le navigateur (même headless)
# la rafraîchit ; sinon login interactif via le profil persistant.
# Renouvellement nécessaire. On privilégie le HTTP pur, sérialisé pour ne pas
# brûler un refresh_token rotatif via deux appels concurrents.
with _refresh_lock:
cached = _read_token_cache() # un autre thread a pu rafraîchir entre-temps
if cached and (time.time() - cached.get("captured_at", 0)) < TOKEN_TTL_S:
return cached["token"]
tok = _refresh_session()
if tok:
return tok
# Dernier recours : navigateur. Session synchronisée → refresh headless ;
# sinon login interactif via le profil persistant (inutile en headless homelab).
if STATE_PATH.exists():
return capture_token(force=force)["token"]
ensure_logged_in()
@@ -331,8 +431,14 @@ def auth_status() -> dict:
tok = token_from_storage_state()
if tok and _token_works(tok):
return {"logged_in": True, "source": "storage_state"}
# Récupération autonome SANS navigateur : refresh via /gw/refresh.
with _refresh_lock:
tok = _refresh_session()
if tok and _token_works(tok):
return {"logged_in": True, "source": "gw_refresh"}
return {
"logged_in": False,
"error": "Session HelloFresh absente ou expirée (aucun token valide accepté par l'API). "
"Refaire le login en local (ANTICOCO_HEADLESS=0) puis re-sync .session/.",
"Refaire le login en local (ANTICOCO_HEADLESS=0, auto-login via HF_EMAIL/"
"HF_PASSWORD ou manuel) puis re-sync .session/.",
}