Files
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

306 lines
13 KiB
Python

"""Master-token headless auth: mint NotebookLM web cookies from a durable Google
``aas_et/`` master token, with no per-session browser.
Flow (proven against the live API in the #1638 spike):
oauth_token (single-use, from one EmbeddedSetup browser sign-in)
--exchange_token--> aas_et/ master token (durable; persisted 0600)
--perform_oauth--> ya29 OAuthLogin token
--OAuthLogin?issueuberauth=1--> uberauth
--MergeSession--> SID/SAPISID/__Secure-1PSID/... cookie jar
The minted jar authorizes the existing web client (batchexecute, upload,
download). After MergeSession the mint also fires one ``RotateCookies`` POST to
add ``__Secure-1PSIDTS`` (the rotating freshness partner of ``__Secure-1PSID``),
so the stored jar is complete at rest. That POST is best-effort — if Google
withholds it, the standard inline recovery still mints it on first load from
``SID`` + ``APISID``/``SAPISID`` (secondary binding).
SECURITY: the master token is full-account, durable, infostealer-grade — use a
dedicated/throwaway account only. Never log the oauth_token, master token, ya29,
uberauth, or cookie values.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import secrets
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
import httpx
# perform_oauth for the OAuthLogin token rides the Chromecast app + signature
# (the spike confirmed the labs-tailwind app's sig downscopes; chromecast yields
# a uberauth-capable token; the labs-tailwind app's sig downscopes to email).
_MASTER_APP = "com.google.android.apps.chromecast.app"
_MASTER_SIG = "24bb24c05e47e0aefa68a58a766179d9b613a600"
_OAUTHLOGIN_SERVICE = "oauth2:https://www.google.com/accounts/OAuthLogin"
# MergeSession requires SID + a secondary binding (APISID+SAPISID or OSID) so the
# client's _recover_psidts_inline can mint __Secure-1PSIDTS on first load.
_REQUIRED_MINTED_COOKIES = {"SID", "APISID", "SAPISID"}
_MASTER_TOKEN_VERSION = 1
logger = logging.getLogger("notebooklm.auth.master_token")
# Serializes the global-logger save/restore in _quiet_gpsoauth_logging so
# overlapping re-mints on different threads (asyncio.to_thread) can't stomp each
# other's saved levels. ponytail: one process-wide lock; the window is one short
# sync RPC, so contention is negligible.
_LOG_LOCK = threading.Lock()
class MasterTokenError(Exception):
"""The master token (or its exchange) was rejected — re-bootstrap needed.
Raised for revoked/expired master tokens, gpsoauth failures, and a minted
cookie jar missing the cookies the web client needs. Carries no secrets.
"""
def _require_gpsoauth() -> Any:
try:
import gpsoauth # noqa: PLC0415 (lazy: optional [headless] extra)
except ImportError as exc: # pragma: no cover - import guard
raise MasterTokenError(
"Master-token auth needs gpsoauth. Install: pip install 'notebooklm-py[headless]'"
) from exc
return gpsoauth
@contextmanager
def _quiet_gpsoauth_logging() -> Iterator[None]:
"""Silence urllib3/requests DEBUG bodies around the gpsoauth call so the
master token / ya29 in request bodies never reach a debug log sink."""
names = ("urllib3", "requests", "urllib3.connectionpool")
with _LOG_LOCK:
saved = {n: logging.getLogger(n).level for n in names}
try:
for n in names:
logging.getLogger(n).setLevel(logging.WARNING)
yield
finally:
for n, lvl in saved.items():
logging.getLogger(n).setLevel(lvl)
def generate_android_id() -> str:
"""Random stable 64-bit hex Android id, generated once per install and
persisted with the token. Changing it can re-trip Google's new-device risk
signal on re-mint, so callers must reuse the stored value."""
return secrets.token_hex(8)
def exchange_master_token(email: str, oauth_token: str, android_id: str) -> str:
"""One-time: a single-use EmbeddedSetup ``oauth_token`` -> durable ``aas_et/``
master token. Raises :class:`MasterTokenError` on rejection (no secret leak)."""
gpsoauth = _require_gpsoauth()
try:
with _quiet_gpsoauth_logging():
res = gpsoauth.exchange_token(email, oauth_token, android_id)
except Exception as exc: # noqa: BLE001 — any gpsoauth/transport failure; never leak the body
raise MasterTokenError("exchange_token failed (network or gpsoauth error).") from exc
token = res.get("Token")
if not token:
# res may carry Error/ErrorDetail (no secrets); include only the code.
raise MasterTokenError(
f"exchange_token rejected the oauth_token (Error={res.get('Error', 'unknown')}). "
"The oauth_token is single-use and short-lived — re-capture it."
)
return str(token)
async def mint_cookies(email: str, master_token: str, android_id: str) -> httpx.Cookies:
"""Mint a fresh NotebookLM web cookie jar from the master token.
perform_oauth (sync, run inline — it is a single short request) -> ya29, then
OAuthLogin?issueuberauth=1 -> uberauth -> MergeSession -> Set-Cookie jar.
Raises :class:`MasterTokenError` if the token is revoked or the jar lacks the
cookies the web client needs.
"""
gpsoauth = _require_gpsoauth()
def _perform() -> Any:
with _quiet_gpsoauth_logging():
return gpsoauth.perform_oauth(
email,
master_token,
android_id,
service=_OAUTHLOGIN_SERVICE,
app=_MASTER_APP,
client_sig=_MASTER_SIG,
)
try:
# perform_oauth is a sync (requests) network call — off-thread it so it
# never blocks the event loop of a live client during layer-4 recovery.
oauth = await asyncio.to_thread(_perform)
except Exception as exc: # noqa: BLE001 — any gpsoauth/transport failure; never leak the body
raise MasterTokenError("perform_oauth failed (network or gpsoauth error).") from exc
bearer = oauth.get("Auth")
if not bearer:
raise MasterTokenError(
f"perform_oauth rejected the master token (Error={oauth.get('Error', 'unknown')}). "
"Re-bootstrap with `notebooklm login --master-token`."
)
# Wrap the cookie-mint HTTP legs: an unwrapped httpx error would escape the
# refresh path AND its ``.request.url`` embeds the uberauth token. Re-raise as
# a secret-free MasterTokenError so the caller declines gracefully.
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
auth = {"Authorization": f"Bearer {bearer}"}
uber = await client.get(
"https://accounts.google.com/OAuthLogin",
params={"source": "ChromiumBrowser", "issueuberauth": "1"},
headers=auth,
)
uberauth = uber.text.strip()
if uber.status_code != 200 or not uberauth or " " in uberauth:
raise MasterTokenError("OAuthLogin did not return a uberauth token.")
await client.get(
"https://accounts.google.com/MergeSession",
params={
"service": "mail",
"continue": "https://www.google.com",
"uberauth": uberauth,
},
headers=auth,
)
# Mint __Secure-1PSIDTS now too (the rotating freshness partner of
# __Secure-1PSID) so the stored jar is complete and valid at rest — no
# first-call recovery needed and `auth check` passes immediately. This
# is the same RotateCookies POST the keepalive/inline recovery use; it
# needs the SID + APISID/SAPISID binding the MergeSession jar already
# carries. Best-effort: Google may withhold it, and inline recovery
# remains the fallback, so a failure here must not fail the mint.
from .keepalive import ( # noqa: PLC0415 (low-level; avoid import cycle)
_KEEPALIVE_ROTATE_BODY,
_KEEPALIVE_ROTATE_HEADERS,
KEEPALIVE_ROTATE_URL,
)
try:
await client.post(
KEEPALIVE_ROTATE_URL,
headers=_KEEPALIVE_ROTATE_HEADERS,
content=_KEEPALIVE_ROTATE_BODY,
)
except httpx.HTTPError as exc:
logger.debug("RotateCookies during mint failed (non-fatal): %s", exc)
jar = httpx.Cookies()
for cookie in client.cookies.jar:
jar.jar.set_cookie(cookie)
except httpx.HTTPError:
raise MasterTokenError(
"cookie minting failed (network error reaching accounts.google.com)."
) from None # drop the httpx __cause__ whose URL carries the uberauth
names = {c.name for c in jar.jar}
missing = _REQUIRED_MINTED_COOKIES - names
if missing:
raise MasterTokenError(
f"Minted cookie jar is missing required cookies: {sorted(missing)}. "
"MergeSession may have changed; the session would fail PSIDTS recovery."
)
return jar
def storage_state_from_jar(jar: httpx.Cookies, *, email: str | None = None) -> dict[str, Any]:
"""Convert a minted jar to a Playwright ``storage_state`` dict the existing
loader (``build_httpx_cookies_from_storage``) consumes, including the
``notebooklm`` account namespace. Reuses ``_cookie_to_storage_state`` so
secure/httpOnly/expires and ``__Secure-`` prefixes survive (see #365)."""
from .cookies import _cookie_to_storage_state # noqa: PLC0415 (avoid import cycle)
state: dict[str, Any] = {
"cookies": [_cookie_to_storage_state(c) for c in jar.jar],
"origins": [],
}
if email is not None:
# Mirrors _auth/account.write_account_metadata's namespace shape.
state["notebooklm"] = {"version": 1, "account": {"authuser": 0, "email": email}}
return state
def persist_minted_jar(path: Path, jar: httpx.Cookies, *, email: str | None) -> None:
"""Replace the cookies in ``storage_state.json`` with a freshly-minted jar,
preserving existing CLI context (notebook_id/conversation_id) and refreshing
the account namespace. Serialized on the shared storage lock so it never
tears against a running keepalive. Old cookies are *replaced*, not merged —
a re-mint is a brand-new session."""
from filelock import FileLock # noqa: PLC0415 (transitive dep)
from .paths import _storage_state_lock_path # noqa: PLC0415 (avoid import cycle)
path.parent.mkdir(parents=True, exist_ok=True)
with FileLock(str(_storage_state_lock_path(path)), timeout=10.0):
data: dict[str, Any] = {}
if path.exists():
try:
loaded = json.loads(path.read_text(encoding="utf-8"))
data = loaded if isinstance(loaded, dict) else {}
except json.JSONDecodeError:
data = {}
data["cookies"] = storage_state_from_jar(jar)["cookies"]
data.setdefault("origins", [])
ns_raw = data.get("notebooklm")
ns: dict[str, Any] = ns_raw if isinstance(ns_raw, dict) else {}
ns["version"] = 1
ns["account"] = {"authuser": 0, **({"email": email} if email else {})}
data["notebooklm"] = ns
# 0600: the jar holds live session cookies (SID/SAPISID/__Secure-1PSID…).
# Hidden .{name}.tmp matches write_master_token so neither leaves a
# visible *.tmp artifact mid-write.
tmp = path.with_name(f".{path.name}.tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f)
tmp.replace(path)
# --- master_token.json persistence (mode 0600, beside storage_state.json) ---
def read_master_token(path: Path) -> dict[str, Any] | None:
"""Read a ``master_token.json`` record, or ``None`` if absent. Raises
:class:`MasterTokenError` on a malformed/old-version file."""
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise MasterTokenError(f"Unreadable master_token.json: {exc}") from exc
if not isinstance(data, dict): # e.g. a bare JSON array — avoid .get AttributeError
raise MasterTokenError("master_token.json is malformed or an unsupported version.")
required = ("master_token", "email", "android_id")
if data.get("version") != _MASTER_TOKEN_VERSION or any(not data.get(k) for k in required):
raise MasterTokenError("master_token.json is malformed or an unsupported version.")
return data
def write_master_token(path: Path, *, email: str, master_token: str, android_id: str) -> None:
"""Persist a master-token record at mode 0600 (full-account credential)."""
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"version": _MASTER_TOKEN_VERSION,
"email": email,
"android_id": android_id,
"master_token": master_token,
}
# Create the temp at 0600 from the start (don't widen-then-chmod) — this is a
# full-account credential. umask cannot widen 0600 (it has no group/other bits).
tmp = path.parent / f".{path.name}.tmp"
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f)
tmp.replace(path)