diff --git a/src/main.py b/src/main.py index 179739e..a58d5a9 100644 --- a/src/main.py +++ b/src/main.py @@ -153,8 +153,6 @@ def auth(ctx: click.Context) -> None: def _auth_chatgpt(os_name: str) -> None: - import jwt as pyjwt - console.print("\n[bold]─── ChatGPT ───[/bold]") console.print("1. Open [link=https://chatgpt.com]https://chatgpt.com[/link] and log in.") if os_name == "Darwin": @@ -175,22 +173,12 @@ def _auth_chatgpt(os_name: str) -> None: token_1 = click.prompt("ChatGPT session token (.1, leave blank if absent)", hide_input=True, default="", show_default=False).strip() or None - # Validate + # Format hint only — the token is a JWE whose expiry can't be decoded + # client-side, so validity is judged by the live check below (the + # /api/auth/session `error` field), not by parsing the token. See §9. if not token.startswith("eyJ"): console.print("[yellow]Warning: token doesn't look like a JWT (expected 'eyJ...').[/yellow]") - expiry_str = "" - try: - payload = pyjwt.decode(token, options={"verify_signature": False}) - exp = payload.get("exp") - if exp: - from datetime import timezone - expiry = datetime.fromtimestamp(exp, tz=timezone.utc) - expiry_str = expiry.strftime("%Y-%m-%d %H:%M UTC") - console.print(f"[green]Token decoded — expires: {expiry_str}[/green]") - except Exception: - console.print("[yellow]Could not decode token expiry.[/yellow]") - # Live validation — exchange session token for an access token _valid = False _error: str | None = None @@ -351,8 +339,6 @@ def _run_doctor_checks(cache: Cache | None = None) -> list[dict]: ``file_path`` recorded in the manifest exists on disk). """ import os - import jwt as pyjwt - from datetime import timezone from dotenv import load_dotenv # Load .env so doctor works without the user having to export vars manually @@ -374,25 +360,21 @@ def _run_doctor_checks(cache: Cache | None = None) -> list[dict]: if chatgpt_token: is_jwt = chatgpt_token.startswith("eyJ") add("ChatGPT token is valid JWT", is_jwt, "" if is_jwt else "Expected token starting with 'eyJ'") + # Expiry is NOT decodable from the token: ChatGPT's is a JWE (encrypted), + # so the `exp` claim is sealed server-side. The honest, free validity + # signal is the `error` field of /api/auth/session — `RefreshAccessTokenError` + # means the session token is dead while `expires`/`accessToken` still + # echo stale values. See FUTURE.md §9. if is_jwt: + from src.providers.chatgpt import ChatGPTProvider + ct1 = os.getenv("CHATGPT_SESSION_TOKEN_1", "").strip() or None try: - payload = pyjwt.decode(chatgpt_token, options={"verify_signature": False}) - exp = payload.get("exp") - if exp: - expiry = datetime.fromtimestamp(exp, tz=timezone.utc) - now = datetime.now(tz=timezone.utc) - delta = expiry - now - detail = f"Expires {expiry.strftime('%Y-%m-%d %H:%M UTC')} ({delta.days}d)" - ok = delta.total_seconds() > 0 - add("ChatGPT token not expired", ok, detail) - if ok and delta.total_seconds() < 86400: - add("ChatGPT token expiry warning", False, "Expires in < 24h — refresh soon") - else: - add("ChatGPT token expiry", False, "JWT has no 'exp' claim") - except pyjwt.exceptions.DecodeError: - # JWE (encrypted JWT) — cannot decode without the server key. - # This is normal for ChatGPT's current token format. Token is present and valid. - add("ChatGPT token expiry", True, "Encrypted token (JWE) — expiry not decodable client-side") + ok, detail = ChatGPTProvider( + chatgpt_token, session_token_1=ct1 + ).session_health() + add("ChatGPT token active", ok, detail) + except Exception as e: + add("ChatGPT token active", False, str(e)[:80]) # Claude key if claude_key: diff --git a/src/providers/chatgpt.py b/src/providers/chatgpt.py index 02dd409..5ecd367 100644 --- a/src/providers/chatgpt.py +++ b/src/providers/chatgpt.py @@ -207,19 +207,51 @@ class ChatGPTProvider(BaseProvider): len(self._project_ids), ) + def _get_session_payload(self) -> dict: + """GET /api/auth/session and return the parsed NextAuth JSON. + + Health signal (verified live 2026-06-28): a healthy session returns + ``accessToken`` with ``error`` absent/null. A *dead* session-token + returns HTTP 200 with ``error == "RefreshAccessTokenError"`` and a + STALE ``accessToken`` — so ``error``, not token presence, is the + reliable validity signal. ``expires`` is a rolling window (it advances + on every call even for a dead token) and must NOT be used to judge + validity. + """ + resp = self._session.get(AUTH_SESSION_URL, timeout=REQUEST_TIMEOUT) + resp.raise_for_status() + return resp.json() + + def session_health(self) -> tuple[bool, str]: + """Return ``(ok, detail)`` describing session-token validity. + + Used by ``doctor`` for a cheap, honest token-health check. Returns + ``ok=False`` with a refresh hint when NextAuth reports an auth + ``error`` or omits the access token. + """ + try: + data = self._get_session_payload() + except Exception as e: # network error / non-JSON / HTTP 4xx-5xx + return False, f"/api/auth/session unreachable: {str(e)[:60]}" + error = data.get("error") + if error: + return False, ( + f"Session expired (error={error}) — refresh " + "__Secure-next-auth.session-token via DevTools / 'auth'" + ) + if not data.get("accessToken"): + return False, "No accessToken — refresh token via DevTools / 'auth'" + return True, "Session active" + def _fetch_access_token(self) -> str: """Exchange the session cookie for a Bearer access token. Calls GET /api/auth/session — the cookie jar already contains the session token, so no manual Cookie header is needed. - - Returns {"accessToken": "...", "user": {...}}. """ logger.debug("[chatgpt] Fetching access token from %s", AUTH_SESSION_URL) try: - resp = self._session.get(AUTH_SESSION_URL, timeout=REQUEST_TIMEOUT) - resp.raise_for_status() - data = resp.json() + data = self._get_session_payload() except Exception as e: raise ProviderError( self.provider_name, @@ -230,6 +262,21 @@ class ChatGPTProvider(BaseProvider): ), ) from e + # A dead session-token returns HTTP 200 with error=RefreshAccessTokenError + # and a stale accessToken. Fail fast here rather than letting the stale + # token produce a confusing downstream 401. + error = data.get("error") + if error: + raise ProviderError( + self.provider_name, + "fetch_access_token", + RuntimeError( + f"Session token expired (/api/auth/session error={error!r}). " + "Refresh __Secure-next-auth.session-token via DevTools, then run " + "'ai-chat-exporter auth' or update CHATGPT_SESSION_TOKEN in .env." + ), + ) + access_token = data.get("accessToken") if not access_token: raise ProviderError( diff --git a/tests/test_providers.py b/tests/test_providers.py index 7b3ccc3..f25e48d 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -873,3 +873,103 @@ class TestChatGPTConvIdFallback: p = self._get_provider() result = p.normalize_conversation(raw) assert result["id"] == "from-id" + + +# --------------------------------------------------------------------------- +# ChatGPT session-token health (§9): /api/auth/session `error` is the signal, +# not `expires`/`accessToken`. Verified live 2026-06-28 — a dead token returns +# HTTP 200 with error=RefreshAccessTokenError and a stale accessToken. +# --------------------------------------------------------------------------- + + +class _FakeResp: + def __init__(self, payload, status=200): + self._payload = payload + self.status_code = status + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + def json(self): + return self._payload + + +class _FakeSession: + """Minimal stand-in for the provider's HTTP session.""" + + def __init__(self, payload=None, status=200, raises=None): + self._payload = payload + self._status = status + self._raises = raises + + def get(self, *args, **kwargs): + if self._raises is not None: + raise self._raises + return _FakeResp(self._payload, self._status) + + +class TestChatGPTSessionHealth: + def _provider(self, session): + from src.providers.chatgpt import ChatGPTProvider + + p = ChatGPTProvider.__new__(ChatGPTProvider) + p._session = session + return p + + def test_fetch_access_token_returns_token_when_healthy(self): + p = self._provider(_FakeSession({"accessToken": "tok-123", "error": None})) + assert p._fetch_access_token() == "tok-123" + + def test_fetch_access_token_fails_fast_on_refresh_error(self): + from src.providers.base import ProviderError + + # Dead token: HTTP 200, error set, stale accessToken still present. + p = self._provider( + _FakeSession({"accessToken": "stale", "error": "RefreshAccessTokenError"}) + ) + with pytest.raises(ProviderError) as exc: + p._fetch_access_token() + assert "expired" in str(exc.value.original).lower() + + def test_fetch_access_token_fails_when_no_token(self): + from src.providers.base import ProviderError + + p = self._provider(_FakeSession({"error": None})) + with pytest.raises(ProviderError): + p._fetch_access_token() + + def test_session_health_ok_when_no_error(self): + p = self._provider(_FakeSession({"accessToken": "tok", "error": None})) + ok, detail = p.session_health() + assert ok is True + assert detail == "Session active" + + def test_session_health_reports_expired_on_error(self): + p = self._provider( + _FakeSession({"accessToken": "stale", "error": "RefreshAccessTokenError"}) + ) + ok, detail = p.session_health() + assert ok is False + assert "RefreshAccessTokenError" in detail + assert "refresh" in detail.lower() + + def test_session_health_does_not_use_rolling_expires(self): + # A far-future `expires` must NOT make a dead token look healthy. + p = self._provider( + _FakeSession( + { + "accessToken": "stale", + "error": "RefreshAccessTokenError", + "expires": "2026-09-26T05:07:31.108Z", + } + ) + ) + ok, _ = p.session_health() + assert ok is False + + def test_session_health_graceful_on_network_error(self): + p = self._provider(_FakeSession(raises=RuntimeError("boom"))) + ok, detail = p.session_health() + assert ok is False + assert "unreachable" in detail.lower()