doctor: report ChatGPT token health via /api/auth/session error field

ChatGPT session tokens are JWEs whose `exp` is encrypted and unreadable
client-side, so the old JWT-decode path could never yield an expiry, and the
`/api/auth/session` `expires` is a misleading rolling window (it advances on
every call even for a dead token). The honest signal is the `error` field:
`RefreshAccessTokenError` means the session token is dead while `expires` and
a stale `accessToken` are still echoed (verified live).

- Add ChatGPTProvider.session_health() and a "ChatGPT token active" doctor
  check based on it; drop the dead JWE/exp decode path in doctor and auth.
- Fix _fetch_access_token to fail fast on a set `error` instead of returning
  the stale accessToken (which produced a confusing downstream 401).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
JesseMarkowitz
2026-06-28 01:37:01 -04:00
co-authored by Claude Opus 4.8
parent 4a7ee5773f
commit ef603cf659
3 changed files with 168 additions and 39 deletions
+16 -34
View File
@@ -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:
+52 -5
View File
@@ -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(
+100
View File
@@ -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()