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

@@ -1,6 +1,10 @@
# Compte HelloFresh (région France : hellofresh.fr) # Compte HelloFresh (région France : hellofresh.fr)
# Optionnels : utilisés seulement comme fallback de re-login auto si la session # En régime normal, le serveur reste authentifié tout seul via POST /gw/refresh
# Playwright (.session/) a expiré. Le login principal se fait en local, fenêtre visible. # (pur HTTP, sans navigateur) : le refresh_token roule par fenêtres de 60 j, donc
# un homelab allumé n'a JAMAIS besoin d'intervention manuelle.
# HF_EMAIL/HF_PASSWORD servent de recovery (re-login auto par navigateur) dans le
# cas rare où le refresh_token est mort (homelab éteint > 60 j) : best-effort,
# peut échouer si captcha/anti-bot — sinon refaire un login local + re-sync.
HF_EMAIL= HF_EMAIL=
HF_PASSWORD= HF_PASSWORD=

View File

@@ -16,9 +16,10 @@ détails batch `recipes/recipes?ids=…`), filtrage coco (4/85 détectées, faux
internes neutralisés), proposition classée, et **écriture réelle réussie** (`PUT /v1/carts/{week}`, internes neutralisés), proposition classée, et **écriture réelle réussie** (`PUT /v1/carts/{week}`,
HTTP 200) — sélection par index de course, ids de compte dérivés dynamiquement. HTTP 200) — sélection par index de course, ids de compte dérivés dynamiquement.
**Auth headless durable** : le token (30 min) est rafraîchi par un navigateur headless chargé **Auth autonome (pur HTTP)** : le token (30 min) est rafraîchi par un simple `POST /gw/refresh`
avec la session (`storage_state.json`, refresh ~60 j) — contourne la protection anti-bot des (le endpoint que la SPA appelle), **sans navigateur**. Le refresh_token roule par fenêtres de 60 j,
endpoints OAuth. Aucune intervention pendant ~60 j ; la session « roule » à chaque refresh. remises à zéro à chaque refresh → un homelab allumé reste authentifié **indéfiniment**, sans
intervention ni re-sync. Le navigateur headless ne sert plus que de filet de secours.
> ⚠️ La connexion **directe** automatisée (Playwright/Chromium qui remplit le formulaire) est > ⚠️ La connexion **directe** automatisée (Playwright/Chromium qui remplit le formulaire) est
> bloquée par l'anti-bot HelloFresh. La session se crée donc via **attache CDP à ton vrai Chrome** > bloquée par l'anti-bot HelloFresh. La session se crée donc via **attache CDP à ton vrai Chrome**
@@ -28,7 +29,7 @@ endpoints OAuth. Aucune intervention pendant ~60 j ; la session « roule » à c
``` ```
Hermes ──HTTP──▶ server.py (FastMCP, :9200/mcp) Hermes ──HTTP──▶ server.py (FastMCP, :9200/mcp)
├─ hellofresh/auth.py session storage_state + refresh token headless ├─ hellofresh/auth.py session storage_state + refresh HTTP /gw/refresh
├─ hellofresh/api.py httpx : menu, détails, deliveries, PUT cart ├─ hellofresh/api.py httpx : menu, détails, deliveries, PUT cart
├─ hellofresh/filter.py exclusion (coco !) + scoring préférences ├─ hellofresh/filter.py exclusion (coco !) + scoring préférences
└─ config/ excludes.json · prefs.json · endpoints.json └─ config/ excludes.json · prefs.json · endpoints.json

View File

@@ -16,6 +16,7 @@ from __future__ import annotations
import base64 import base64
import json import json
import os import os
import threading
import time import time
import urllib.parse import urllib.parse
from pathlib import Path from pathlib import Path
@@ -30,6 +31,20 @@ TOKEN_CACHE = SESSION_DIR / "token.json"
STATE_PATH = SESSION_DIR / "storage_state.json" STATE_PATH = SESSION_DIR / "storage_state.json"
BASE_URL = "https://www.hellofresh.fr" 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). # Page qui déclenche des appels gateway authentifiés (menu de la semaine).
MENU_PAGE = f"{BASE_URL}/my-account/deliveries/menu" MENU_PAGE = f"{BASE_URL}/my-account/deliveries/menu"
ACCOUNT_PAGE = f"{BASE_URL}/my-account" ACCOUNT_PAGE = f"{BASE_URL}/my-account"
@@ -274,12 +289,88 @@ def token_from_storage_state() -> str | None:
return 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: def get_token(force: bool = False) -> str:
"""Renvoie un bearer token valide, par ordre de préférence : """Renvoie un bearer token valide, par ordre de préférence :
1. token en cache encore frais ; 1. token en cache encore frais ;
2. cookie `apiV2Auth` de la session synchronisée (sans navigateur) ; 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: if not force:
cached = _read_token_cache() cached = _read_token_cache()
@@ -288,8 +379,17 @@ def get_token(force: bool = False) -> str:
tok = token_from_storage_state() tok = token_from_storage_state()
if tok: if tok:
return tok return tok
# Refresh nécessaire : si on a une session synchronisée, le navigateur (même headless) # Renouvellement nécessaire. On privilégie le HTTP pur, sérialisé pour ne pas
# la rafraîchit ; sinon login interactif via le profil persistant. # 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(): if STATE_PATH.exists():
return capture_token(force=force)["token"] return capture_token(force=force)["token"]
ensure_logged_in() ensure_logged_in()
@@ -331,8 +431,14 @@ def auth_status() -> dict:
tok = token_from_storage_state() tok = token_from_storage_state()
if tok and _token_works(tok): if tok and _token_works(tok):
return {"logged_in": True, "source": "storage_state"} 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 { return {
"logged_in": False, "logged_in": False,
"error": "Session HelloFresh absente ou expirée (aucun token valide accepté par l'API). " "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/.",
} }