From 4a7ee5773f74581f9d10f9e780031985eca90fcd Mon Sep 17 00:00:00 2001 From: JesseMarkowitz Date: Sun, 28 Jun 2026 01:35:42 -0400 Subject: [PATCH] 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 --- README.md | 18 ++-- pyproject.toml | 1 - requirements.txt | 1 - src/browser_tokens.py | 101 ----------------------- src/main.py | 100 +--------------------- tests/test_browser_tokens.py | 155 ----------------------------------- 6 files changed, 10 insertions(+), 366 deletions(-) delete mode 100644 src/browser_tokens.py delete mode 100644 tests/test_browser_tokens.py diff --git a/README.md b/README.md index 94619c4..7417607 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index a66bf28..432f989 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/requirements.txt b/requirements.txt index 64a8018..dab0d28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/browser_tokens.py b/src/browser_tokens.py deleted file mode 100644 index 0bbd3db..0000000 --- a/src/browser_tokens.py +++ /dev/null @@ -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." - ) diff --git a/src/main.py b/src/main.py index aa12ab0..179739e 100644 --- a/src/main.py +++ b/src/main.py @@ -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): diff --git a/tests/test_browser_tokens.py b/tests/test_browser_tokens.py deleted file mode 100644 index 7f2e7ef..0000000 --- a/tests/test_browser_tokens.py +++ /dev/null @@ -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