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:
|
def _auth_chatgpt(os_name: str) -> None:
|
||||||
import jwt as pyjwt
|
|
||||||
|
|
||||||
console.print("\n[bold]─── ChatGPT ───[/bold]")
|
console.print("\n[bold]─── ChatGPT ───[/bold]")
|
||||||
console.print("1. Open [link=https://chatgpt.com]https://chatgpt.com[/link] and log in.")
|
console.print("1. Open [link=https://chatgpt.com]https://chatgpt.com[/link] and log in.")
|
||||||
if os_name == "Darwin":
|
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
|
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"):
|
if not token.startswith("eyJ"):
|
||||||
console.print("[yellow]Warning: token doesn't look like a JWT (expected 'eyJ...').[/yellow]")
|
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
|
# Live validation — exchange session token for an access token
|
||||||
_valid = False
|
_valid = False
|
||||||
_error: str | None = None
|
_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).
|
``file_path`` recorded in the manifest exists on disk).
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import jwt as pyjwt
|
|
||||||
from datetime import timezone
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Load .env so doctor works without the user having to export vars manually
|
# 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:
|
if chatgpt_token:
|
||||||
is_jwt = chatgpt_token.startswith("eyJ")
|
is_jwt = chatgpt_token.startswith("eyJ")
|
||||||
add("ChatGPT token is valid JWT", is_jwt, "" if is_jwt else "Expected token starting with '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:
|
if is_jwt:
|
||||||
|
from src.providers.chatgpt import ChatGPTProvider
|
||||||
|
ct1 = os.getenv("CHATGPT_SESSION_TOKEN_1", "").strip() or None
|
||||||
try:
|
try:
|
||||||
payload = pyjwt.decode(chatgpt_token, options={"verify_signature": False})
|
ok, detail = ChatGPTProvider(
|
||||||
exp = payload.get("exp")
|
chatgpt_token, session_token_1=ct1
|
||||||
if exp:
|
).session_health()
|
||||||
expiry = datetime.fromtimestamp(exp, tz=timezone.utc)
|
add("ChatGPT token active", ok, detail)
|
||||||
now = datetime.now(tz=timezone.utc)
|
except Exception as e:
|
||||||
delta = expiry - now
|
add("ChatGPT token active", False, str(e)[:80])
|
||||||
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")
|
|
||||||
|
|
||||||
# Claude key
|
# Claude key
|
||||||
if claude_key:
|
if claude_key:
|
||||||
|
|||||||
@@ -207,19 +207,51 @@ class ChatGPTProvider(BaseProvider):
|
|||||||
len(self._project_ids),
|
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:
|
def _fetch_access_token(self) -> str:
|
||||||
"""Exchange the session cookie for a Bearer access token.
|
"""Exchange the session cookie for a Bearer access token.
|
||||||
|
|
||||||
Calls GET /api/auth/session — the cookie jar already contains the
|
Calls GET /api/auth/session — the cookie jar already contains the
|
||||||
session token, so no manual Cookie header is needed.
|
session token, so no manual Cookie header is needed.
|
||||||
|
|
||||||
Returns {"accessToken": "...", "user": {...}}.
|
|
||||||
"""
|
"""
|
||||||
logger.debug("[chatgpt] Fetching access token from %s", AUTH_SESSION_URL)
|
logger.debug("[chatgpt] Fetching access token from %s", AUTH_SESSION_URL)
|
||||||
try:
|
try:
|
||||||
resp = self._session.get(AUTH_SESSION_URL, timeout=REQUEST_TIMEOUT)
|
data = self._get_session_payload()
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ProviderError(
|
raise ProviderError(
|
||||||
self.provider_name,
|
self.provider_name,
|
||||||
@@ -230,6 +262,21 @@ class ChatGPTProvider(BaseProvider):
|
|||||||
),
|
),
|
||||||
) from e
|
) 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")
|
access_token = data.get("accessToken")
|
||||||
if not access_token:
|
if not access_token:
|
||||||
raise ProviderError(
|
raise ProviderError(
|
||||||
|
|||||||
@@ -873,3 +873,103 @@ class TestChatGPTConvIdFallback:
|
|||||||
p = self._get_provider()
|
p = self._get_provider()
|
||||||
result = p.normalize_conversation(raw)
|
result = p.normalize_conversation(raw)
|
||||||
assert result["id"] == "from-id"
|
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