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:
@@ -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=
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/.",
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user