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:
@@ -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"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user