fix: detect claude.ai's 403 session-invalid as an expired key; ChatGPT .1 cookie is optional

claude.ai answers an invalid or expired sessionKey with 403 account_session_invalid, never 401, so the refresh-your-cookie message could not fire for Claude. Auth detection is now a provider decision (_is_auth_failure); Claude matches the 403 on its error code so a real permission error still reports as itself. README and .env.example no longer claim both ChatGPT cookie chunks are required.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbnmGHnFqDjyhPcCg1SEfF
This commit is contained in:
JesseMarkowitz
2026-10-05 07:14:01 -04:00
co-authored by Claude Opus 5.5
parent d3745e1de4
commit b6ce636891
8 changed files with 256 additions and 30 deletions
+127
View File
@@ -1184,6 +1184,133 @@ class TestErrorBodyDiagnostics:
assert "[REDACTED]" in described
# ---------------------------------------------------------------------------
# Auth failure detection: claude.ai reports an invalid/expired sessionKey as
# 403 permission_error / account_session_invalid, never 401. Verified live
# 2026-09-20 against GET /api/organizations — a valid key returned 200, while
# an expired key, a garbage key and no cookie at all returned byte-identical
# 403s. Keying auth handling on 401 alone hid the refresh instructions behind
# a raw JSON dump.
# ---------------------------------------------------------------------------
class TestAuthFailureDetection:
class _Resp:
reason = ""
headers: dict = {}
def __init__(self, status, payload=None):
self.status_code = status
self.ok = 200 <= status < 400
self._payload = payload
self.text = ""
def json(self):
if self._payload is None:
raise ValueError("not json")
return self._payload
@staticmethod
def _bare(cls, response):
p = cls.__new__(cls)
p._request_delay = 0
p._last_request_at = None
p._session = type("S", (), {"request": lambda *a, **k: response})()
return p
@staticmethod
def _session_invalid(message="Invalid authorization"):
return {
"type": "error",
"error": {
"type": "permission_error",
"message": message,
"details": {
"error_code": "account_session_invalid",
"error_visibility": "user_facing",
},
},
}
def test_claude_403_session_invalid_is_an_auth_failure(self):
from src.providers.base import ProviderError
from src.providers.claude import ClaudeProvider
resp = self._Resp(403, self._session_invalid())
prov = self._bare(ClaudeProvider, resp)
assert prov._is_auth_failure(resp) is True
with pytest.raises(ProviderError) as exc:
prov._make_request("GET", "https://claude.ai/api/organizations")
# The actionable message, not a dump of the response body.
assert exc.value.operation == "authentication"
assert "session key expired or invalid" in str(exc.value.original)
def test_claude_403_auth_failure_tells_the_user_how_to_refresh(self, caplog):
from src.providers.base import ProviderError
from src.providers.claude import ClaudeProvider
resp = self._Resp(403, self._session_invalid())
with caplog.at_level(logging.ERROR):
with pytest.raises(ProviderError):
self._bare(ClaudeProvider, resp)._make_request(
"GET", "https://claude.ai/api/organizations"
)
logged = caplog.text
assert "sessionKey" in logged
assert "CLAUDE_SESSION_KEY" in logged
# States the status it actually saw, rather than claiming 401.
assert "403" in logged
assert "401 Unauthorized" not in logged
def test_claude_403_with_another_error_code_is_not_an_auth_failure(self):
"""A genuine permission problem must stay reported as itself."""
from src.providers.base import ProviderError
from src.providers.claude import ClaudeProvider
payload = {
"type": "error",
"error": {
"type": "permission_error",
"message": "Organization access denied",
"details": {"error_code": "org_access_denied"},
},
}
resp = self._Resp(403, payload)
prov = self._bare(ClaudeProvider, resp)
assert prov._is_auth_failure(resp) is False
with pytest.raises(ProviderError) as exc:
prov._make_request("GET", "https://claude.ai/api/organizations")
assert exc.value.operation != "authentication"
assert "Organization access denied" in str(exc.value.original)
def test_claude_403_with_unparseable_body_is_not_an_auth_failure(self):
from src.providers.claude import ClaudeProvider
resp = self._Resp(403, None)
assert self._bare(ClaudeProvider, resp)._is_auth_failure(resp) is False
def test_claude_401_is_still_an_auth_failure(self):
from src.providers.claude import ClaudeProvider
resp = self._Resp(401, {})
assert self._bare(ClaudeProvider, resp)._is_auth_failure(resp) is True
def test_chatgpt_403_is_not_an_auth_failure(self):
"""Guards the deleted-asset path: a media 403 is not an expired token."""
from src.providers.chatgpt import ChatGPTProvider
resp = self._Resp(403, {"detail": "Forbidden"})
assert self._bare(ChatGPTProvider, resp)._is_auth_failure(resp) is False
def test_chatgpt_401_is_an_auth_failure(self):
from src.providers.chatgpt import ChatGPTProvider
resp = self._Resp(401, {})
assert self._bare(ChatGPTProvider, resp)._is_auth_failure(resp) is True
# ---------------------------------------------------------------------------
# Deleted assets: ChatGPT's download endpoint answers a missing upload with
# 403 Forbidden, not 404. Measured 2026-08-17 over 18 such assets — every one