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
306 lines
13 KiB
Python
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)
|