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(
|
||||
|
||||
Reference in New Issue
Block a user