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:
co-authored by
Claude Opus 5.5
parent
d3745e1de4
commit
b6ce636891
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user