Files
teng-lin--notebooklm-py/src/notebooklm/_auth/cookies.py
T
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

646 lines
25 KiB
Python

"""Cookie conversion and jar helpers for authentication.
This private module is safe to import directly. Runtime cookie policy lives in
:mod:`notebooklm._auth.cookie_policy`; ``notebooklm.auth`` passively re-exports
the compatibility names.
"""
from __future__ import annotations
import http.cookiejar
import json
import logging
import os
from pathlib import Path
from typing import Any, TypeAlias
import httpx
from ..paths import get_storage_path
from . import cookie_policy as _cookie_policy
logger = logging.getLogger("notebooklm.auth")
CookieKey: TypeAlias = tuple[str, str, str]
DomainCookieMap: TypeAlias = dict[CookieKey, str]
FlatCookieMap: TypeAlias = dict[str, str]
# ``CookieInput`` also accepts the legacy ``(name, domain) -> value`` shape that
# pre-#369 callers constructed by hand; :func:`normalize_cookie_map` widens
# those entries to ``(name, domain, "/")`` so the rest of the pipeline sees a
# uniform path-aware shape.
LegacyDomainCookieMap: TypeAlias = dict[tuple[str, str], str]
CookieInput: TypeAlias = DomainCookieMap | LegacyDomainCookieMap | FlatCookieMap
MINIMUM_REQUIRED_COOKIES = _cookie_policy.MINIMUM_REQUIRED_COOKIES
_EXTRACTION_HINT = _cookie_policy._EXTRACTION_HINT
_auth_domain_priority = _cookie_policy._auth_domain_priority
_is_allowed_auth_domain = _cookie_policy._is_allowed_auth_domain
_is_allowed_cookie_domain = _cookie_policy._is_allowed_cookie_domain
# Local alias to the canonical validator. The validator reads policy constants
# from ``_auth.cookie_policy`` at call time; tests that rebind policy state
# should patch that owning module directly.
_validate_required_cookies = _cookie_policy._validate_required_cookies
def normalize_cookie_map(cookies: CookieInput | None) -> DomainCookieMap:
"""Normalize flat or domain-aware cookie maps into ``(name, domain, path)`` keys.
Accepts three input shapes for back-compat:
- Path-aware ``(name, domain, path) -> value`` (the canonical post-#369 shape).
- Legacy ``(name, domain) -> value`` — kept so external callers that built a
``DomainCookieMap`` against the pre-#369 type alias keep working. The
missing path component defaults to ``/``.
- Flat ``name -> value`` — assigned to ``.google.com`` / ``/`` for backward
compatibility with very old callers.
"""
normalized: DomainCookieMap = {}
if not cookies:
return normalized
for key, value in cookies.items():
if isinstance(key, tuple):
if len(key) == 3:
name, domain, path = key
elif len(key) == 2:
name, domain = key
path = "/"
else:
logger.warning(
"Dropping malformed cookie key %r (expected (name, domain[, path]))",
key,
)
continue
else:
name, domain, path = key, ".google.com", "/"
if name:
normalized[(name, domain or ".google.com", path or "/")] = value
return normalized
def flatten_cookie_map(cookies: CookieInput | None) -> FlatCookieMap:
"""Flatten domain-aware cookies for legacy raw Cookie header callers.
Duplicate-name resolution mirrors :func:`extract_cookies_from_storage`:
domains are ranked by :func:`_auth_domain_priority` (``.google.com`` >
``.notebooklm.google.com`` > ``notebooklm.google.com`` > regional > other).
Named tiers are strictly distinct, so the cross-tier case from #375 (e.g.
``OSID`` on ``myaccount.google.com`` (tier 0) vs ``notebooklm.google.com``
(tier 2)) resolves the same way regardless of input order. Within a single
tier, first occurrence in iteration order wins — matching
:func:`extract_cookies_from_storage`'s within-tier semantics.
Path is intentionally collapsed here (#369): the legacy ``Cookie:`` header
that consumes the flat shape carries only ``name=value`` pairs, with no slot
for path. When two cookies share ``(name, domain)`` at different paths, the
first one observed during iteration of the normalized map wins. This is
deterministic but **not** RFC 6265 §5.4 path-specificity ordering — callers
that need accurate path-aware behavior must use ``cookie_jar`` or the
``DomainCookieMap`` directly.
"""
flat: FlatCookieMap = {}
priorities: dict[str, int] = {}
for (name, domain, _path), value in normalize_cookie_map(cookies).items():
priority = _auth_domain_priority(domain)
if name not in flat or priority > priorities[name]:
flat[name] = value
priorities[name] = priority
return flat
def convert_rookiepy_cookies_to_storage_state(
rookiepy_cookies: list[dict],
) -> dict[str, Any]:
"""Convert rookiepy cookie dicts to Playwright storage_state.json format.
Key mappings:
- ``http_only`` → ``httpOnly`` (snake_case to camelCase)
- ``expires=None`` → ``expires=-1`` (Playwright convention for session cookies)
- ``sameSite`` always ``"None"`` for cross-site Google cookies
Args:
rookiepy_cookies: List of cookie dicts from any ``rookiepy.*()`` call.
Required keys: ``domain``, ``name``, ``value``.
Returns:
Dict matching storage_state.json schema: ``{"cookies": [...], "origins": []}``.
Cookies missing required fields or from non-allowlisted domains are silently skipped.
"""
converted = []
for cookie in rookiepy_cookies:
domain = cookie.get("domain", "")
name = cookie.get("name", "")
value = cookie.get("value", "")
if not name or not value or not domain:
continue
if not _is_allowed_auth_domain(domain):
continue
path = cookie.get("path", "/")
http_only = cookie.get("http_only", False)
secure = cookie.get("secure", False)
expires = cookie.get("expires")
converted.append(
{
"name": name,
"value": value,
"domain": domain,
"path": path,
"expires": expires if expires is not None else -1,
"httpOnly": http_only,
"secure": secure,
"sameSite": "None",
}
)
return {"cookies": converted, "origins": []}
def extract_cookies_from_storage(storage_state: dict[str, Any]) -> dict[str, str]:
"""Extract Google cookies from Playwright storage state for NotebookLM auth.
Filters through the canonical auth-domain allowlist: the NotebookLM hosts,
Google auth hosts (``.google.com`` / ``accounts.google.com`` plus regional
ccTLDs), Googleusercontent media domains, Drive-ingest domains, and any
optional sibling-product domains already present because the user opted in
at extraction time.
Cookie Priority Rules:
When the same cookie name exists on multiple domains (e.g., SID on both
.google.com and .google.com.sg), we use this priority order:
1. .google.com (base domain) - ALWAYS preferred when present
2. .notebooklm.google.com (Playwright canonical NotebookLM subdomain)
3. notebooklm.google.com (no-dot NotebookLM subdomain)
4. Regional domains (e.g. .google.de, .google.com.sg, .google.co.uk)
5. Other allowlisted domains (e.g. .googleusercontent.com)
Within a single priority tier, the first occurrence in the list wins;
later duplicates at the same tier are ignored. Tiers are distinct so the
outcome is deterministic regardless of storage_state ordering. See PR #34
for the bug this fixes.
Args:
storage_state: Parsed JSON from Playwright's storage state file.
Returns:
Dict mapping cookie names to values.
Raises:
ValueError: If required cookies (SID + ``__Secure-1PSIDTS``) are missing
from storage state.
Example:
>>> storage = {"cookies": [
... {"name": "SID", "value": "regional", "domain": ".google.com.sg"},
... {"name": "SID", "value": "base", "domain": ".google.com"},
... {"name": "__Secure-1PSIDTS", "value": "tts", "domain": ".google.com"},
... {"name": "APISID", "value": "apisid", "domain": ".google.com"},
... {"name": "SAPISID", "value": "sapisid", "domain": ".google.com"},
... ]}
>>> cookies = extract_cookies_from_storage(storage)
>>> cookies["SID"]
'base' # .google.com wins regardless of list order
"""
cookies = {}
cookie_domains: dict[str, str] = {}
cookie_priorities: dict[str, int] = {}
for cookie in storage_state.get("cookies", []):
domain = cookie.get("domain", "")
name = cookie.get("name")
if not _is_allowed_auth_domain(domain) or not name:
continue
priority = _auth_domain_priority(domain)
if name not in cookies or priority > cookie_priorities[name]:
if name in cookies:
logger.debug(
"Cookie %s: using %s value (overriding %s)",
name,
domain,
cookie_domains[name],
)
cookies[name] = cookie.get("value", "")
cookie_domains[name] = domain
cookie_priorities[name] = priority
else:
logger.debug(
"Cookie %s: ignoring duplicate from %s (keeping %s)",
name,
domain,
cookie_domains[name],
)
if cookie_domains:
unique_domains = sorted(set(cookie_domains.values()))
logger.debug(
"Extracted %d cookies from domains: %s", len(cookies), ", ".join(unique_domains)
)
if "SID" in cookie_domains:
logger.debug("SID cookie from domain: %s", cookie_domains["SID"])
cookie_names = set(cookies.keys())
extras: list[str] = []
if not MINIMUM_REQUIRED_COOKIES.issubset(cookie_names):
all_domains = {c.get("domain", "") for c in storage_state.get("cookies", [])}
google_domains = sorted(d for d in all_domains if "google" in d.lower())
found_names = list(cookies.keys())[:5]
if found_names:
extras.append(f"Found cookies: {found_names}{'...' if len(cookies) > 5 else ''}")
if google_domains:
extras.append(f"Google domains in storage: {google_domains}")
_validate_required_cookies(cookie_names, extra_diagnostics=extras)
return cookies
def _load_storage_state(path: Path | None = None) -> dict[str, Any]:
"""Load Playwright storage state from file or environment variable.
This is a shared helper used by load_auth_from_storage() and load_httpx_cookies()
to avoid code duplication.
Precedence:
1. Explicit path argument (from --storage CLI flag)
2. NOTEBOOKLM_AUTH_JSON environment variable (inline JSON, no file needed)
3. Profile storage path from :func:`notebooklm.paths.get_storage_path`
(``$NOTEBOOKLM_HOME/profiles/<profile>/storage_state.json`` with legacy
home-root fallback for the default profile)
Args:
path: Path to storage_state.json. If provided, takes precedence over env vars.
Returns:
Parsed storage state dict.
Raises:
FileNotFoundError: If storage file doesn't exist (when using file-based auth).
ValueError: If JSON is malformed or empty.
"""
if path:
if not path.exists():
raise FileNotFoundError(
f"Storage file not found: {path}\nRun 'notebooklm login' to authenticate first."
)
return json.loads(path.read_text(encoding="utf-8"))
if "NOTEBOOKLM_AUTH_JSON" in os.environ:
auth_json = os.environ["NOTEBOOKLM_AUTH_JSON"].strip()
if not auth_json:
raise ValueError(
"NOTEBOOKLM_AUTH_JSON environment variable is set but empty.\n"
"Provide valid Playwright storage state JSON or unset the variable."
)
try:
storage_state = json.loads(auth_json)
except json.JSONDecodeError as e:
raise ValueError(
f"Invalid JSON in NOTEBOOKLM_AUTH_JSON environment variable: {e}\n"
f"Ensure the value is valid Playwright storage state JSON."
) from e
if not isinstance(storage_state, dict) or "cookies" not in storage_state:
raise ValueError(
"NOTEBOOKLM_AUTH_JSON must contain valid Playwright storage state "
"with a 'cookies' key.\n"
'Expected format: {"cookies": [{"name": "SID", "value": "...", ...}]}'
)
return storage_state
storage_path = get_storage_path()
if not storage_path.exists():
raise FileNotFoundError(
f"Storage file not found: {storage_path}\nRun 'notebooklm login' to authenticate first."
)
return json.loads(storage_path.read_text(encoding="utf-8"))
def load_httpx_cookies(path: Path | None = None) -> httpx.Cookies:
"""Load cookies as an httpx.Cookies object for authenticated downloads.
Unlike load_auth_from_storage() which returns a simple dict, this function
returns a proper httpx.Cookies object with domain information preserved.
This is required for downloads that follow redirects across Google domains.
Supports the same precedence as load_auth_from_storage():
1. Explicit path argument (from --storage CLI flag)
2. NOTEBOOKLM_AUTH_JSON environment variable
3. Profile storage path from :func:`notebooklm.paths.get_storage_path`
(with legacy home-root fallback for the default profile)
Args:
path: Path to storage_state.json. If provided, takes precedence over env vars.
Returns:
httpx.Cookies object with all Google cookies.
Raises:
FileNotFoundError: If storage file doesn't exist (when using file-based auth).
ValueError: If required cookies are missing or JSON is malformed.
"""
storage_state = _load_storage_state(path)
cookies = httpx.Cookies()
cookie_names: set[str] = set()
for entry in storage_state.get("cookies", []):
domain = entry.get("domain", "")
name = entry.get("name", "")
value = entry.get("value", "")
if _is_allowed_cookie_domain(domain) and name and value:
cookies.jar.set_cookie(_storage_entry_to_cookie(entry))
cookie_names.add(name)
_validate_required_cookies(cookie_names, context=" for downloads")
return cookies
def extract_cookies_with_domains(
storage_state: dict[str, Any],
) -> DomainCookieMap:
"""Extract Google cookies from storage state preserving original identity.
Returns a path-aware ``(name, domain, path) -> value`` map per RFC 6265 §5.3.
Two cookies sharing ``(name, domain)`` at distinct paths survive as
independent entries instead of one silently shadowing the other (issue #369).
Args:
storage_state: Parsed JSON from Playwright's storage state file.
Returns:
Dict mapping ``(cookie_name, domain, path)`` tuples to values.
Example: ``{("SID", ".google.com", "/"): "abc123"}``.
Raises:
ValueError: If required cookies (SID + ``__Secure-1PSIDTS``) are missing
from storage state.
"""
cookie_map: DomainCookieMap = {}
for cookie in storage_state.get("cookies", []):
domain = cookie.get("domain", "")
name = cookie.get("name")
value = cookie.get("value", "")
if not _is_allowed_auth_domain(domain) or not name or not value:
continue
key = (name, domain, cookie.get("path") or "/")
if key not in cookie_map:
cookie_map[key] = value
_validate_required_cookies({name for name, _, _ in cookie_map})
return cookie_map
def build_httpx_cookies_from_storage(path: Path | None = None) -> httpx.Cookies:
"""Build an httpx.Cookies jar with original domains preserved.
This function loads cookies from storage and creates a proper httpx.Cookies
jar with the original domains intact. This is critical for cross-domain
redirects (e.g., to accounts.google.com for token refresh) to work correctly.
Args:
path: Path to storage_state.json. If provided, takes precedence over env vars.
Returns:
httpx.Cookies jar with all cookies set to their original domains.
Raises:
FileNotFoundError: If storage file doesn't exist.
ValueError: If required cookies are missing or JSON is malformed.
"""
try:
return _build_httpx_cookies_from_storage_strict(path)
except ValueError:
# Inline ``__Secure-1PSIDTS`` recovery (issue #865) — same as the
# ``load_auth_from_storage`` hook in ``notebooklm.auth``. Without
# this, ``AuthTokens.from_storage`` and ``NotebookLMClient.from_storage``
# would still hit the closed loop because they use this loader
# directly, bypassing ``load_auth_from_storage``.
from . import psidts_recovery
if not psidts_recovery._recover_psidts_inline(path):
raise
return _build_httpx_cookies_from_storage_strict(path)
def _build_httpx_cookies_from_storage_strict(path: Path | None) -> httpx.Cookies:
"""Inner load-and-validate body. No recovery — raises ``ValueError`` directly."""
storage_state = _load_storage_state(path)
cookies = httpx.Cookies()
seen_names: set[str] = set()
seen_keys: set[CookieKey] = set()
for entry in storage_state.get("cookies", []):
domain = entry.get("domain", "")
name = entry.get("name")
value = entry.get("value", "")
if not _is_allowed_auth_domain(domain) or not name or not value:
continue
key = (name, domain, entry.get("path") or "/")
if key in seen_keys:
continue
seen_keys.add(key)
seen_names.add(name)
cookies.jar.set_cookie(_storage_entry_to_cookie(entry))
_validate_required_cookies(seen_names)
return cookies
def build_cookie_jar(
cookies: CookieInput | None = None,
storage_path: Path | None = None,
) -> httpx.Cookies:
"""Build an httpx.Cookies jar with original domains preserved.
This is the SINGLE authoritative place to construct cookie jars.
Priority:
1. If storage_path exists, load from storage with original domains
2. Otherwise, use provided cookies while preserving domain keys. Legacy
flat mappings are assigned to .google.com for backward compatibility.
Args:
cookies: Path-aware ``(name, domain, path)`` cookie dict (the
canonical post-#369 shape), legacy ``(name, domain)`` cookie
dict, or legacy flat ``name -> value`` dict. The latter two are
widened via :func:`normalize_cookie_map` — missing path defaults
to ``/``, missing domain to ``.google.com``.
storage_path: Path to storage_state.json with domain metadata.
Returns:
httpx.Cookies jar populated with auth cookies.
"""
if storage_path and storage_path.exists():
return build_httpx_cookies_from_storage(storage_path)
jar = httpx.Cookies()
for (name, domain, path), value in normalize_cookie_map(cookies).items():
jar.set(name, value, domain=domain, path=path)
return jar
def _cookie_is_http_only(cookie: Any) -> bool:
"""Return whether an http.cookiejar.Cookie has the HttpOnly marker."""
try:
return bool(
cookie.has_nonstandard_attr("HttpOnly") or cookie.has_nonstandard_attr("httponly")
)
except AttributeError:
return False
def _cookie_to_storage_state(cookie: Any) -> dict[str, Any]:
"""Convert an http.cookiejar.Cookie to a Playwright storage_state cookie."""
return {
"name": cookie.name,
"value": cookie.value,
"domain": cookie.domain,
"path": cookie.path or "/",
"expires": cookie.expires if cookie.expires is not None else -1,
"httpOnly": _cookie_is_http_only(cookie),
"secure": cookie.secure,
"sameSite": "None",
}
def _storage_entry_to_cookie(entry: dict[str, Any]) -> http.cookiejar.Cookie:
"""Construct a faithful ``http.cookiejar.Cookie`` from a storage_state entry.
``httpx.Cookies.set(name, value, domain=...)`` accepts only those three
fields, so cookies loaded that way drop ``path``, ``secure``, and
``httpOnly``. Each load+save round-trip would erode attributes until disk
stabilized at ``Path=/``, ``secure=false``, ``httpOnly=false`` — silently
breaking ``__Host-`` prefix invariants and any future server-enforced
attribute. This helper is the load-side mirror of
:func:`_cookie_to_storage_state` so the round-trip is lossless. See #365.
"""
domain = entry.get("domain", "") or ""
expires = entry.get("expires")
expires_value = None if expires in (None, -1) else expires
rest: dict[str, str] = {"HttpOnly": ""} if entry.get("httpOnly") else {}
return http.cookiejar.Cookie(
version=0,
name=entry.get("name", "") or "",
value=entry.get("value", "") or "",
port=None,
port_specified=False,
domain=domain,
domain_specified=bool(domain),
domain_initial_dot=domain.startswith("."),
path=entry.get("path") or "/",
path_specified=True,
secure=bool(entry.get("secure", False)),
expires=expires_value,
discard=expires_value is None,
comment=None,
comment_url=None,
rest=rest,
)
def _cookie_key_variants(key: CookieKey) -> set[CookieKey]:
"""Return equivalent host/domain cookie keys for leading-dot domains.
The path component is preserved verbatim (issue #369): RFC 6265 §5.3 treats
``path`` as part of cookie identity, so variants only span the leading-dot
domain normalization that ``http.cookiejar`` applies.
"""
name, domain, path = key
variants = {key}
if domain.startswith("."):
variants.add((name, domain[1:], path))
else:
variants.add((name, f".{domain}", path))
return variants
def _find_cookie_for_storage(
cookies_by_key: dict[CookieKey, Any], key: CookieKey, stored_value: str | None
) -> Any | None:
"""Find the best refreshed cookie for a stored cookie key.
http.cookiejar normalizes ``Domain=accounts.google.com`` to
``.accounts.google.com``. If both the original host-only key and the
normalized domain key exist, prefer the value that differs from storage
because that is the refreshed Set-Cookie value. Path is held fixed across
variants so a same-name sibling on a different path can't be returned by
accident (issue #369).
"""
candidates = [
cookie
for variant in _cookie_key_variants(key)
if (cookie := cookies_by_key.get(variant)) is not None
]
if not candidates:
return None
for cookie in candidates:
if cookie.value != stored_value:
return cookie
return candidates[0]
def _replace_cookie_jar(target: httpx.Cookies, source: httpx.Cookies) -> None:
"""Replace target jar contents with source jar contents."""
if target is source:
return
target.jar.clear()
for cookie in source.jar:
target.jar.set_cookie(cookie)
def _cookie_map_from_jar(cookie_jar: httpx.Cookies) -> DomainCookieMap:
"""Extract a path-aware auth cookie map from an httpx cookie jar.
Path-aware identity (issue #369) keeps two cookies that share ``(name,
domain)`` but differ on ``path`` from collapsing into a single map entry
on the way into ``AuthTokens.cookies``.
"""
return {
(cookie.name, cookie.domain, cookie.path or "/"): cookie.value
for cookie in cookie_jar.jar
if cookie.name
and cookie.domain
and cookie.value is not None
and _is_allowed_auth_domain(cookie.domain)
}
def _update_cookie_input(target: CookieInput, fresh: DomainCookieMap) -> None:
"""Update caller-provided cookies in place while preserving key style.
The caller's ``target`` may use any of the three accepted shapes (flat
``name -> value``, legacy ``(name, domain) -> value``, or path-aware
``(name, domain, path) -> value``). The freshly-fetched delta is always the
path-aware shape; we collapse it back to the caller's original shape so
they don't observe an in-place type change.
"""
if any(isinstance(key, tuple) and len(key) == 2 for key in target):
# Legacy 2-tuple caller. Collapse the path dimension by keeping the
# first occurrence per (name, domain); for cookies that share name and
# domain at distinct paths this is lossy, but legacy callers had no
# way to express path either, so this matches their original contract.
legacy: dict[tuple[str, str], str] = {}
for (name, domain, _path), value in fresh.items():
legacy.setdefault((name, domain), value)
target.clear()
target.update(legacy) # type: ignore[arg-type]
return
use_domain_keys = any(isinstance(key, tuple) for key in target)
target.clear()
if use_domain_keys:
target.update(fresh) # type: ignore[arg-type]
else:
target.update(flatten_cookie_map(fresh)) # type: ignore[arg-type]