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:
co-authored by
Claude Opus 4.8
parent
4a7ee5773f
commit
ef603cf659
+16
-34
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user