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
+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(