Remove auth --from-browser browser cookie extraction
Browser cookie auto-extraction is not viable: modern Chromium App-Bound Encryption (Chrome 127+/current Brave) keys cookies off a SYSTEM-level layer that cannot be decrypted off disk without admin rights and AV-flagged SYSTEM impersonation, and fails on Brave specifically. Drop the `--from-browser` flag, `_auth_from_browser`, `src/browser_tokens.py`, its test, and the browser-cookie3 dependency. Auth is manual (DevTools wizard) only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3cf0b1eaa8
commit
4a7ee5773f
@@ -87,23 +87,22 @@ This checks token presence, format, expiry, directory permissions, disk space, a
|
||||
|
||||
Session tokens are how your browser stays logged in. This tool uses them to access your chat history on your behalf.
|
||||
|
||||
### The fast way: extract from your browser
|
||||
|
||||
If the browser you're logged in with runs on the same machine as this tool:
|
||||
Tokens are entered manually — copy them from your browser's DevTools and run the wizard:
|
||||
|
||||
```bash
|
||||
ai-chat-exporter auth --from-browser # Brave (default)
|
||||
ai-chat-exporter auth --from-browser firefox # or chrome, chromium, edge
|
||||
ai-chat-exporter auth
|
||||
```
|
||||
|
||||
This reads the session cookies straight from the browser's cookie store (decrypted via the OS keyring/DPAPI/Keychain), validates each token against the live API, and writes `.env` — no DevTools, no copy-pasting. A token that fails validation never overwrites a working one. If extraction fails (browser not installed here, cookie DB locked, logged out), fall back to the manual flow below.
|
||||
The wizard detects your OS, shows the correct DevTools shortcut, and writes the values to `.env` without echoing them to the terminal.
|
||||
|
||||
> **Why no auto-extraction from the browser?** Modern Chromium browsers (Chrome 127+, current Brave) encrypt cookies on Windows with **App-Bound Encryption**: the key is bound to the browser through a SYSTEM-level service, so reading cookies off disk requires Administrator rights and a PsExec-style SYSTEM impersonation that antivirus flags as credential theft — and it still fails on Brave specifically. There is no reliable, non-invasive way to pull these cookies automatically, so the manual DevTools flow below is the supported path.
|
||||
|
||||
### Token Lifetimes
|
||||
|
||||
| Provider | Cookie Name | Lifetime | Expiry Detection |
|
||||
|----------|-------------|----------|-----------------|
|
||||
| ChatGPT | `__Secure-next-auth.session-token.0` + `.1` | ~7 days | JWT `exp` claim (decoded automatically) |
|
||||
| Claude | `sessionKey` | ~30 days | Only detectable via 401 response |
|
||||
| ChatGPT | `__Secure-next-auth.session-token.0` + `.1` | refresh ~weekly | `error` field of `/api/auth/session` — `doctor` reports "ChatGPT token active". The token is an encrypted JWE, so its `exp` is **not** readable client-side, and the `expires` field is a misleading rolling window; the `error` (`RefreshAccessTokenError` when dead) is the honest signal. |
|
||||
| Claude | `sessionKey` | ~30 days | Opaque token — only detectable via 401 response |
|
||||
|
||||
### Finding Tokens in Chrome DevTools
|
||||
|
||||
@@ -285,10 +284,9 @@ Each provider+project combination maps to a flat Joplin notebook created automat
|
||||
|
||||
```bash
|
||||
ai-chat-exporter auth # manual wizard (DevTools flow)
|
||||
ai-chat-exporter auth --from-browser # extract from Brave's cookie store
|
||||
```
|
||||
|
||||
Guided wizard to find and save session tokens and ChatGPT project IDs. Detects OS and shows the correct DevTools shortcut. `--from-browser [brave|chrome|chromium|edge|firefox]` skips the wizard and pulls tokens directly from a local browser, validating them live before writing `.env`.
|
||||
Guided wizard to find and save session tokens and ChatGPT project IDs. Detects OS and shows the correct DevTools shortcut, then writes the values to `.env`.
|
||||
|
||||
### `doctor` — Health check
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ dependencies = [
|
||||
"rich==13.7.1",
|
||||
"python-slugify==8.0.4",
|
||||
"PyJWT==2.8.0",
|
||||
"browser-cookie3==0.20.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -26,4 +26,3 @@ setuptools==82.0.0
|
||||
text-unidecode==1.3
|
||||
urllib3==2.6.3
|
||||
wheel==0.46.3
|
||||
browser-cookie3==0.20.1
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
"""Extract session tokens directly from a local browser's cookie store.
|
||||
|
||||
Default browser is Brave. Uses browser-cookie3, which handles the
|
||||
platform-specific cookie decryption (Linux: AES key derived from the OS
|
||||
keyring "Safe Storage" secret; Windows: DPAPI; macOS: Keychain). The browser
|
||||
must be the one the user is actually logged in with, on the same machine.
|
||||
|
||||
Failure modes worth knowing:
|
||||
- Browser not installed / profile not found → BrowserTokenError
|
||||
- Cookie DB locked (browser running with exclusive lock; rare on Linux,
|
||||
common on Windows) → BrowserTokenError suggesting the browser be closed
|
||||
- Logged out / cookie expired → BrowserTokenError naming the missing cookie
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_BROWSER = "brave"
|
||||
SUPPORTED_BROWSERS = ("brave", "chrome", "chromium", "edge", "firefox")
|
||||
|
||||
# ChatGPT splits large session tokens across two cookies to stay under the
|
||||
# browser's 4KB cookie limit; small tokens use the unsuffixed name.
|
||||
_CHATGPT_COOKIE_0 = "__Secure-next-auth.session-token.0"
|
||||
_CHATGPT_COOKIE_1 = "__Secure-next-auth.session-token.1"
|
||||
_CHATGPT_COOKIE_SINGLE = "__Secure-next-auth.session-token"
|
||||
_CLAUDE_COOKIE = "sessionKey"
|
||||
|
||||
|
||||
class BrowserTokenError(Exception):
|
||||
"""Raised when tokens cannot be extracted from the browser profile."""
|
||||
|
||||
|
||||
def _load_cookies(browser: str, domain: str) -> dict[str, str]:
|
||||
"""Return {cookie_name: value} for ``domain`` from the given browser."""
|
||||
if browser not in SUPPORTED_BROWSERS:
|
||||
raise BrowserTokenError(
|
||||
f"Unsupported browser {browser!r}. "
|
||||
f"Supported: {', '.join(SUPPORTED_BROWSERS)}"
|
||||
)
|
||||
|
||||
import browser_cookie3
|
||||
|
||||
loader = getattr(browser_cookie3, browser)
|
||||
try:
|
||||
jar = loader(domain_name=domain)
|
||||
except Exception as e:
|
||||
# browser_cookie3 raises BrowserCookieError plus assorted OS/keyring
|
||||
# errors. All mean the same thing to the caller: fall back to manual.
|
||||
hint = ""
|
||||
text = str(e).lower()
|
||||
if "lock" in text or "database is locked" in text:
|
||||
hint = " Close the browser and try again."
|
||||
elif "not find" in text or "no such file" in text:
|
||||
hint = " Is the browser installed on this machine?"
|
||||
raise BrowserTokenError(
|
||||
f"Could not read {browser} cookies for {domain}: {e}.{hint}"
|
||||
) from e
|
||||
|
||||
return {cookie.name: cookie.value or "" for cookie in jar}
|
||||
|
||||
|
||||
def extract_chatgpt_tokens(browser: str = DEFAULT_BROWSER) -> tuple[str, str | None]:
|
||||
"""Return (session_token_0, session_token_1_or_None) from the browser.
|
||||
|
||||
Raises BrowserTokenError when no session cookie is present (not logged in,
|
||||
or logged out since the last visit).
|
||||
"""
|
||||
cookies = _load_cookies(browser, "chatgpt.com")
|
||||
token_0 = cookies.get(_CHATGPT_COOKIE_0, "").strip()
|
||||
token_1 = cookies.get(_CHATGPT_COOKIE_1, "").strip() or None
|
||||
if token_0:
|
||||
logger.info(
|
||||
"[browser-tokens] ChatGPT session cookies found in %s (chunked: %s)",
|
||||
browser,
|
||||
"yes" if token_1 else "no",
|
||||
)
|
||||
return token_0, token_1
|
||||
|
||||
single = cookies.get(_CHATGPT_COOKIE_SINGLE, "").strip()
|
||||
if single:
|
||||
logger.info("[browser-tokens] ChatGPT session cookie found in %s (single)", browser)
|
||||
return single, None
|
||||
|
||||
raise BrowserTokenError(
|
||||
f"No ChatGPT session cookie in {browser} — open https://chatgpt.com "
|
||||
"in that browser and log in, then retry."
|
||||
)
|
||||
|
||||
|
||||
def extract_claude_session(browser: str = DEFAULT_BROWSER) -> str:
|
||||
"""Return the Claude ``sessionKey`` cookie value from the browser."""
|
||||
cookies = _load_cookies(browser, "claude.ai")
|
||||
session_key = cookies.get(_CLAUDE_COOKIE, "").strip()
|
||||
if session_key:
|
||||
logger.info("[browser-tokens] Claude sessionKey found in %s", browser)
|
||||
return session_key
|
||||
raise BrowserTokenError(
|
||||
f"No Claude sessionKey cookie in {browser} — open https://claude.ai "
|
||||
"in that browser and log in, then retry."
|
||||
)
|
||||
+2
-98
@@ -117,37 +117,17 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool, debug: bool, no_log_file
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
"--from-browser",
|
||||
"from_browser",
|
||||
is_flag=False,
|
||||
flag_value="brave",
|
||||
default=None,
|
||||
metavar="[BROWSER]",
|
||||
help=(
|
||||
"Extract tokens straight from a local browser's cookie store instead "
|
||||
"of the manual DevTools flow. Defaults to brave when no browser is "
|
||||
"named; also supports chrome, chromium, edge, firefox. The browser "
|
||||
"must be installed and logged in on this machine."
|
||||
),
|
||||
)
|
||||
@click.pass_context
|
||||
def auth(ctx: click.Context, from_browser: str | None) -> None:
|
||||
def auth(ctx: click.Context) -> None:
|
||||
"""Interactive setup wizard for session tokens.
|
||||
|
||||
Guides you through finding and saving your ChatGPT and Claude session
|
||||
tokens. Tokens are never echoed to the terminal. With --from-browser,
|
||||
tokens are pulled from the browser's cookie store and written to .env
|
||||
without any copy-pasting.
|
||||
tokens. Tokens are never echoed to the terminal.
|
||||
|
||||
Token lifetimes:
|
||||
ChatGPT (__Secure-next-auth.session-token): ~7 days (JWT)
|
||||
Claude (sessionKey): ~30 days (opaque string)
|
||||
"""
|
||||
if from_browser:
|
||||
_auth_from_browser(from_browser.lower())
|
||||
return
|
||||
|
||||
os_name = platform.system()
|
||||
|
||||
console.print("\n[bold cyan]AI Chat Exporter — Token Setup Wizard[/bold cyan]\n")
|
||||
@@ -307,82 +287,6 @@ def _auth_claude(os_name: str) -> None:
|
||||
_write_token_to_env("CLAUDE_SESSION_KEY", key)
|
||||
|
||||
|
||||
def _auth_from_browser(browser: str) -> None:
|
||||
"""Extract ChatGPT + Claude tokens from a local browser and write .env.
|
||||
|
||||
Each provider is validated against its live API before anything is
|
||||
written, so a stale cookie (logged out since last visit) never replaces
|
||||
a working token. Exits 1 only when nothing could be configured.
|
||||
"""
|
||||
from src.browser_tokens import (
|
||||
BrowserTokenError,
|
||||
SUPPORTED_BROWSERS,
|
||||
extract_chatgpt_tokens,
|
||||
extract_claude_session,
|
||||
)
|
||||
|
||||
if browser not in SUPPORTED_BROWSERS:
|
||||
err_console.print(
|
||||
f"[red]Unsupported browser '{browser}'. "
|
||||
f"Supported: {', '.join(SUPPORTED_BROWSERS)}[/red]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
console.print(f"\n[bold cyan]Extracting session tokens from {browser}…[/bold cyan]\n")
|
||||
configured = 0
|
||||
|
||||
# ── ChatGPT ──────────────────────────────────────────────────────────
|
||||
try:
|
||||
token, token_1 = extract_chatgpt_tokens(browser)
|
||||
with console.status("[dim]Validating ChatGPT token…[/dim]"):
|
||||
from src.providers.chatgpt import ChatGPTProvider
|
||||
prov = ChatGPTProvider(session_token=token, session_token_1=token_1)
|
||||
prov._fetch_access_token()
|
||||
_set_env_key("CHATGPT_SESSION_TOKEN", token)
|
||||
_set_env_key("CHATGPT_SESSION_TOKEN_1", token_1 or "")
|
||||
console.print("[green]ChatGPT: token extracted, validated, and saved.[/green]")
|
||||
configured += 1
|
||||
except BrowserTokenError as e:
|
||||
console.print(f"[yellow]ChatGPT: {e}[/yellow]")
|
||||
except ProviderError as e:
|
||||
console.print(
|
||||
f"[yellow]ChatGPT: extracted cookie failed live validation "
|
||||
f"({e.original}) — .env not changed. Log in to chatgpt.com in "
|
||||
f"{browser} and retry.[/yellow]"
|
||||
)
|
||||
|
||||
# ── Claude ───────────────────────────────────────────────────────────
|
||||
try:
|
||||
session_key = extract_claude_session(browser)
|
||||
with console.status("[dim]Validating Claude session key…[/dim]"):
|
||||
from src.providers.claude import ClaudeProvider
|
||||
prov = ClaudeProvider(session_key)
|
||||
prov.list_conversations(offset=0, limit=1)
|
||||
_set_env_key("CLAUDE_SESSION_KEY", session_key)
|
||||
console.print("[green]Claude: session key extracted, validated, and saved.[/green]")
|
||||
configured += 1
|
||||
except BrowserTokenError as e:
|
||||
console.print(f"[yellow]Claude: {e}[/yellow]")
|
||||
except ProviderError as e:
|
||||
console.print(
|
||||
f"[yellow]Claude: extracted cookie failed live validation "
|
||||
f"({e.original}) — .env not changed. Log in to claude.ai in "
|
||||
f"{browser} and retry.[/yellow]"
|
||||
)
|
||||
|
||||
if configured:
|
||||
console.print(
|
||||
f"\n[green]Done — {configured} provider(s) configured. "
|
||||
"Run 'ai-chat-exporter doctor' to verify.[/green]"
|
||||
)
|
||||
else:
|
||||
err_console.print(
|
||||
"\n[red]No tokens could be extracted. Use the manual wizard "
|
||||
"instead: ai-chat-exporter auth[/red]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _write_token_to_env(key: str, value: str) -> None:
|
||||
"""Write or update a key in .env, offering to create the file if it doesn't exist."""
|
||||
if click.confirm(f"Write {key} to .env?", default=True):
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
"""Unit tests for browser cookie extraction (browser_cookie3 mocked)."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src.browser_tokens import (
|
||||
BrowserTokenError,
|
||||
extract_chatgpt_tokens,
|
||||
extract_claude_session,
|
||||
)
|
||||
|
||||
|
||||
def _fake_bc3(monkeypatch, cookies_by_domain, raises=None):
|
||||
"""Install a stub browser_cookie3 module whose ``brave`` loader returns
|
||||
SimpleNamespace cookies for the requested domain (or raises)."""
|
||||
module = types.ModuleType("browser_cookie3")
|
||||
|
||||
def brave(domain_name=""):
|
||||
if raises is not None:
|
||||
raise raises
|
||||
return [
|
||||
SimpleNamespace(name=name, value=value)
|
||||
for name, value in cookies_by_domain.get(domain_name, {}).items()
|
||||
]
|
||||
|
||||
module.brave = brave
|
||||
monkeypatch.setitem(sys.modules, "browser_cookie3", module)
|
||||
return module
|
||||
|
||||
|
||||
class TestExtractChatGPT:
|
||||
def test_chunked_cookies(self, monkeypatch):
|
||||
_fake_bc3(monkeypatch, {
|
||||
"chatgpt.com": {
|
||||
"__Secure-next-auth.session-token.0": "eyJpart0",
|
||||
"__Secure-next-auth.session-token.1": "part1rest",
|
||||
},
|
||||
})
|
||||
assert extract_chatgpt_tokens("brave") == ("eyJpart0", "part1rest")
|
||||
|
||||
def test_single_cookie_fallback(self, monkeypatch):
|
||||
_fake_bc3(monkeypatch, {
|
||||
"chatgpt.com": {"__Secure-next-auth.session-token": "eyJsingle"},
|
||||
})
|
||||
assert extract_chatgpt_tokens("brave") == ("eyJsingle", None)
|
||||
|
||||
def test_missing_cookie_raises_with_login_hint(self, monkeypatch):
|
||||
_fake_bc3(monkeypatch, {"chatgpt.com": {"unrelated": "x"}})
|
||||
with pytest.raises(BrowserTokenError, match="log in"):
|
||||
extract_chatgpt_tokens("brave")
|
||||
|
||||
def test_loader_failure_wrapped(self, monkeypatch):
|
||||
_fake_bc3(monkeypatch, {}, raises=RuntimeError("database is locked"))
|
||||
with pytest.raises(BrowserTokenError, match="Close the browser"):
|
||||
extract_chatgpt_tokens("brave")
|
||||
|
||||
def test_unsupported_browser_rejected(self):
|
||||
with pytest.raises(BrowserTokenError, match="Unsupported browser"):
|
||||
extract_chatgpt_tokens("netscape")
|
||||
|
||||
|
||||
class TestExtractClaude:
|
||||
def test_session_key(self, monkeypatch):
|
||||
_fake_bc3(monkeypatch, {"claude.ai": {"sessionKey": "sk-ant-sid01-xyz"}})
|
||||
assert extract_claude_session("brave") == "sk-ant-sid01-xyz"
|
||||
|
||||
def test_missing_raises(self, monkeypatch):
|
||||
_fake_bc3(monkeypatch, {"claude.ai": {}})
|
||||
with pytest.raises(BrowserTokenError, match="log in"):
|
||||
extract_claude_session("brave")
|
||||
|
||||
|
||||
class TestAuthFromBrowserCLI:
|
||||
def test_writes_env_after_validation(self, monkeypatch, tmp_path):
|
||||
"""--from-browser extracts, validates live (mocked), and writes .env."""
|
||||
from click.testing import CliRunner
|
||||
from src.main import cli
|
||||
from src.cache import Cache
|
||||
|
||||
cache = Cache(tmp_path / "cache")
|
||||
cache.acknowledge_tos()
|
||||
|
||||
_fake_bc3(monkeypatch, {
|
||||
"chatgpt.com": {
|
||||
"__Secure-next-auth.session-token.0": "eyJtok0",
|
||||
"__Secure-next-auth.session-token.1": "tok1",
|
||||
},
|
||||
"claude.ai": {"sessionKey": "sk-ant-sid01-abc"},
|
||||
})
|
||||
|
||||
# Stub out the live validation calls.
|
||||
import src.providers.chatgpt as chatgpt_mod
|
||||
import src.providers.claude as claude_mod
|
||||
monkeypatch.setattr(
|
||||
chatgpt_mod.ChatGPTProvider, "_fetch_access_token", lambda self: "at"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
claude_mod.ClaudeProvider,
|
||||
"list_conversations",
|
||||
lambda self, offset=0, limit=100: [],
|
||||
)
|
||||
|
||||
runner = CliRunner(mix_stderr=True)
|
||||
with runner.isolated_filesystem(temp_dir=tmp_path):
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--no-log-file", "auth", "--from-browser"],
|
||||
env={"CACHE_DIR": str(tmp_path / "cache")},
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
from pathlib import Path
|
||||
env_text = Path(".env").read_text()
|
||||
|
||||
assert "CHATGPT_SESSION_TOKEN=eyJtok0" in env_text
|
||||
assert "CHATGPT_SESSION_TOKEN_1=tok1" in env_text
|
||||
assert "CLAUDE_SESSION_KEY=sk-ant-sid01-abc" in env_text
|
||||
assert "2 provider(s) configured" in result.output
|
||||
|
||||
def test_validation_failure_leaves_env_untouched(self, monkeypatch, tmp_path):
|
||||
from click.testing import CliRunner
|
||||
from src.main import cli
|
||||
from src.cache import Cache
|
||||
from src.providers.base import ProviderError
|
||||
|
||||
cache = Cache(tmp_path / "cache")
|
||||
cache.acknowledge_tos()
|
||||
|
||||
_fake_bc3(monkeypatch, {
|
||||
"chatgpt.com": {"__Secure-next-auth.session-token.0": "eyJstale"},
|
||||
"claude.ai": {},
|
||||
})
|
||||
|
||||
import src.providers.chatgpt as chatgpt_mod
|
||||
|
||||
def _fail(self):
|
||||
raise ProviderError("chatgpt", "auth", RuntimeError("401"))
|
||||
|
||||
monkeypatch.setattr(chatgpt_mod.ChatGPTProvider, "_fetch_access_token", _fail)
|
||||
|
||||
runner = CliRunner(mix_stderr=True)
|
||||
with runner.isolated_filesystem(temp_dir=tmp_path):
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--no-log-file", "auth", "--from-browser", "brave"],
|
||||
env={"CACHE_DIR": str(tmp_path / "cache")},
|
||||
)
|
||||
import pathlib
|
||||
env_exists = pathlib.Path(".env").exists()
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "failed live validation" in result.output
|
||||
assert not env_exists
|
||||
Reference in New Issue
Block a user