Auth headless durable via storage_state + refresh navigateur (homelab-ready)

- capture_token s'appuie sur storage_state.json (cookies ~60j) en new_context :
  fonctionne headless, la SPA rafraîchit le token (contourne l'anti-bot OAuth)
- session 'roule' (storage_state ré-exporté à chaque refresh) ; access token 30min,
  refresh token 60j
- goto en domcontentloaded + attente (networkidle ne se déclenche jamais sur la SPA)
- Dockerfile/Playwright alignés en 1.60.0 (chromium préinstallé) ; doc déploiement maj :
  session créée via attach_capture (login direct Playwright bloqué par anti-bot)
This commit is contained in:
2026-06-15 23:08:09 +02:00
parent 051ecb50d8
commit 5d3899fdfb
4 changed files with 104 additions and 65 deletions

View File

@@ -139,56 +139,84 @@ def ensure_logged_in() -> bool:
ctx.close()
def capture_token(force: bool = False) -> dict:
"""Capture (ou relit depuis le cache) le bearer token et les hôtes gateway observés.
def _capture_from_context(ctx, roll_state: bool) -> dict:
"""Charge le menu dans `ctx` et capture le bearer token (refraîchi par la SPA).
Retourne {"token": str, "gateways": [str], "captured_at": float}.
`roll_state=True` : ré-exporte storage_state.json après coup, pour faire « rouler »
la session (le refresh_token tourne ~60 j) sans intervention manuelle.
"""
observed = {"token": None, "gateways": set()}
page = ctx.pages[0] if ctx.pages else ctx.new_page()
def on_request(req):
try:
if not _is_gateway_request(req.url):
return
base = req.url.split("/gw/")[0] + "/gw" if "/gw/" in req.url else req.url
observed["gateways"].add(base)
a = req.headers.get("authorization") or req.headers.get("Authorization")
if a and a.lower().startswith("bearer "):
observed["token"] = a.split(" ", 1)[1].strip()
except Exception:
pass
page.on("request", on_request)
page.goto(MENU_PAGE, wait_until="domcontentloaded", timeout=45000)
# SPA lourde : on attend que les appels gateway (et un éventuel refresh) partent,
# plutôt que networkidle qui ne se déclenche jamais.
for _ in range(15):
page.wait_for_timeout(1000)
if observed["token"]:
page.wait_for_timeout(1000)
break
if not observed["token"]:
raise RuntimeError(
"Aucun bearer token capturé (session expirée ? refaire le login + re-sync). "
)
if roll_state:
try:
ctx.storage_state(path=str(STATE_PATH))
except Exception:
pass
result = {
"token": observed["token"],
"gateways": sorted(observed["gateways"]),
"captured_at": time.time(),
}
TOKEN_CACHE.write_text(json.dumps(result, indent=2), encoding="utf-8")
return result
def capture_token(force: bool = False) -> dict:
"""Renvoie un bearer token frais (et les hôtes gateway), via navigateur.
Préfère `storage_state.json` (cookies 60 j) → fonctionne en **headless** sur le homelab :
la SPA rafraîchit elle-même le token (contourne la protection anti-bot des endpoints
OAuth bruts). À défaut, retombe sur le profil persistant (login interactif).
"""
cached = _read_token_cache()
if cached and not force and (time.time() - cached.get("captured_at", 0)) < TOKEN_TTL_S:
return cached
SESSION_DIR.mkdir(parents=True, exist_ok=True)
observed = {"token": None, "gateways": set()}
with sync_playwright() as pw:
if STATE_PATH.exists():
launch = {"headless": _headless()}
if _channel():
launch["channel"] = _channel()
browser = pw.chromium.launch(**launch)
ctx = browser.new_context(storage_state=str(STATE_PATH), locale="fr-FR")
try:
return _capture_from_context(ctx, roll_state=True)
finally:
ctx.close()
browser.close()
ctx = _open_context(pw)
try:
page = ctx.pages[0] if ctx.pages else ctx.new_page()
def on_request(req):
try:
if not _is_gateway_request(req.url):
return
base = req.url.split("/gw/")[0] + "/gw" if "/gw/" in req.url else req.url
observed["gateways"].add(base)
auth = req.headers.get("authorization") or req.headers.get("Authorization")
if auth and auth.lower().startswith("bearer "):
observed["token"] = auth.split(" ", 1)[1].strip()
except Exception:
pass
page.on("request", on_request)
page.goto(MENU_PAGE, wait_until="networkidle", timeout=45000)
page.wait_for_timeout(3000)
if not _is_logged_in(page):
raise RuntimeError(
"Non connecté lors de la capture du token. Lancer ensure_logged_in() d'abord."
)
if not observed["token"]:
raise RuntimeError(
"Aucun bearer token capturé. Vérifier MENU_PAGE / le pattern gateway, "
"ou rejouer tools/discover_api.py."
)
result = {
"token": observed["token"],
"gateways": sorted(observed["gateways"]),
"captured_at": time.time(),
}
TOKEN_CACHE.write_text(json.dumps(result, indent=2), encoding="utf-8")
return result
return _capture_from_context(ctx, roll_state=False)
finally:
ctx.close()
@@ -248,6 +276,10 @@ 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.
if STATE_PATH.exists():
return capture_token(force=force)["token"]
ensure_logged_in()
return capture_token(force=force)["token"]