Fix expired OAuth credential fallback for Anthropic auth
Cluster 21107 · agent. Data as of
Nothing open on GitHub. Fix merged in #53899 on 28 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #53899. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #53899. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address a bug where Anthropic authentication fails when the macOS Keychain and the credentials file hold different or expired OAuth tokens. The fix reconciles the two sources by falling back to the credential file when the keychain token is expired.
Open issues0
Open PRs0
Fixes on record
-
#53899 fix(anthropic): reconcile keychain/file OAuth credentials when one is expired
files:
agent/anthropic_adapter.py,scripts/release.py,tests/agent/test_anthropic_keychain.pyShow the diff
-
agent/anthropic_adapter.py@@ -914,44 +914,72 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]: return None +def _read_claude_code_credentials_from_file() -> Optional[Dict[str, Any]]: + """Read Claude Code OAuth credentials from ~/.claude/.credentials.json. + + Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None. + """ + cred_path = Path.home() / ".claude" / ".credentials.json" + if not cred_path.exists(): + return None + try: + data = json.loads(cred_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError, IOError) as e: + logger.debug("Failed to read ~/.claude/.credentials.json: %s", e) + return None + + oauth_data = data.get("claudeAiOauth") + if not (oauth_data and isinstance(oauth_data, dict)): + return None + access_token = oauth_data.get("accessToken", "") + if not access_token: + return None + return { + "accessToken": [redacted:secret-assignment] + "refreshToken": oauth_data.get("refreshToken", ""), + "expiresAt": oauth_data.get("expiresAt", 0), + "source": "claude_code_credentials_file", + } + + def read_claude_code_credentials() -> Optional[Dict[str, Any]]: """Read refreshable Claude Code OAuth credentials. - Checks two sources in order: + Reads from two possible sources and reconciles them: 1. macOS Keychain (Darwin only) — "Claude Code-credentials" entry 2. ~/.claude/.credentials.json file + Selection rules when both are present: + - If exactly one is non-expired, prefer that one. (Handles the case + where Claude Code refreshes one source but not the other — observed + in the wild on Claude Code 2.1.x.) + - Otherwise, prefer the source with the later ``expiresAt`` so that + any subsequent refresh uses the most recent ``refreshToken``. + This intentionally excludes ~/.claude.json primaryApiKey. Opencode's subscription flow is OAuth/setup-token based with refreshable credentials, and native direct Anthropic provider usage should follow that path rather than auto-detecting Claude's first-party managed key. - Returns dict with {accessToken, refreshToken?, expiresAt?} or None. + Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None. """ - # Try macOS Keychain first (covers Claude Code >=2.1.114) kc_creds = _read_claude_code_credentials_from_keychain() - if kc_creds: - return kc_creds + file_creds = _read_claude_code_credentials_from_file() - # Fall back to JSON file - cred_path = Path.home() / ".claude" / ".credentials.json" - if cred_path.exists(): - try: - data = json.loads(cred_path.read_text(encoding="utf-8")) - oauth_data = data.get("claudeAiOauth") - if oauth_data and isinstance(oauth_data, dict): - access_token = oauth_data.get("accessToken", "") - if access_token: - return { - "accessToken": [redacted:secret-assignment] - "refreshToken": oauth_data.get("refreshToken", ""), - "expiresAt": oauth_data.get("expiresAt", 0), - "source": "claude_code_credentials_file", - } - except (json.JSONDecodeError, OSError, IOError) as e: - logger.debug("Failed to read ~/.claude/.credentials.json: %s", e) + if kc_creds and file_creds: + kc_valid = is_claude_code_token_valid(kc_creds) + file_valid = is_claude_code_token_valid(file_creds) + if kc_valid and not file_valid: + return kc_creds + if file_valid and not kc_valid: + return file_creds + # Both valid or both expired: prefer the later expiresAt so the + # downstream refresh path uses the freshest refresh_token. + kc_exp = kc_creds.get("expiresAt", 0) or 0 + file_exp = file_creds.get("expiresAt", 0) or 0 + return kc_creds if kc_exp >= file_exp else file_creds - return None + return kc_creds or file_creds def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool: @@ -1034,8 +1062,40 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False) def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]: - """Attempt to refresh an expired Claude Code OAuth token.""" - refresh_token = creds.get("refreshToken", "") + """Attempt to refresh an expired Claude Code OAuth token. + + Claude Code's OAuth refresh tokens are single-use: a successful refresh + rotates the pair and invalidates the old refresh token. Claude Code itself + also refreshes on its own schedule (IDE/CLI activity), so by the time + Hermes notices an expired token, Claude Code may have already rotated it. + POSTing our now-stale refresh token in that window races Claude Code and + fails with ``invalid_grant``. + + So before refreshing, re-read the live credential sources. If Claude Code + has already produced a valid token, adopt it and skip the POST entirely. + Only fall back to refreshing ourselves when no fresh credential is found. + """ + # Claude Code may have already refreshed — adopt its token rather than + # racing it with our (possibly already-rotated) refresh token. Only adopt + # when the live re-read produced a DIFFERENT token with a real future + # expiry: re-adopting the same credential we were just handed would be a + # no-op, and a 0/absent ``expiresAt`` means "managed key / unknown expiry" + # (see is_claude_code_token_valid) which must NOT be treated as a fresh + # refresh here. + current = read_claude_code_credentials() + if current: + current_token = current.get("accessToken", "") + current_exp = current.get("expiresAt", 0) or 0 + if ( + current_token + and current_token != creds.get("accessToken", "") + and current_exp > 0 + and is_claude_code_token_valid(current) + ): + logger.debug("Adopted Claude Code's already-refreshed OAuth token") + return current_token + + refresh_token = (current or {}).get("refreshToken", "") or creds.get("refreshToken", "") if not refresh_token: logger.debug("No refresh token available — cannot refresh") return None -
scripts/release.py@@ -1573,6 +1573,7 @@ "[redacted:email]": "davidcampbelldc", # PR #26834 (web_server proxy_headers=False) "[email protected]": "davidcampbelldc", "[redacted:email]": "chazmaniandinkle", # PR #43888 (launchd /restart detection) + "[redacted:email]": "nodejun", # PR #21112 (anthropic keychain/file credential desync) "[redacted:email]": "hehehe0803", # PR #26212 salvage (codex kanban writable root) "[email protected]": "hehehe0803", "[email protected]": "kasunvinod", # PR #24126 salvage (codex timeout propagation) -
tests/agent/test_anthropic_keychain.py@@ -7,6 +7,7 @@ from agent.anthropic_adapter import ( _read_claude_code_credentials_from_keychain, read_claude_code_credentials, + _refresh_oauth_token, ) @@ -161,3 +162,176 @@ def test_returns_none_when_neither_keychain_nor_json_has_creds(self, tmp_path, m creds = read_claude_code_credentials() assert creds is None + + +class TestReadClaudeCodeCredentialsDesync: + """Reconciliation when Keychain and JSON file disagree. + + Observed in the wild on Claude Code 2.1.x: a refresh updates one source + (commonly the JSON file) but leaves the other holding an expired token. + The reader must not blindly return whichever source it consulted first; + it must prefer the non-expired credential. + """ + + # Far-future ms-epoch — comfortably valid under is_claude_code_token_valid. + _FRESH = 9_999_999_999_999 + # Past ms-epoch — comfortably expired (with the 60s buffer). + _EXPIRED = 1 + + def _setup(self, tmp_path, monkeypatch, *, file_expires_at, file_token="[redacted:secret-assignment]"): + json_cred_file = tmp_path / ".claude" / ".credentials.json" + json_cred_file.parent.mkdir(parents=True) + json_cred_file.write_text(json.dumps({ + "claudeAiOauth": { + "accessToken": [redacted:secret-assignment] + "refreshToken": "[redacted:secret-assignment]", + "expiresAt": file_expires_at, + } + })) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + + def _keychain_payload(self, *, access_token, expires_at, refresh_token="[redacted:secret-assignment]"): + return MagicMock( + returncode=0, + stdout=json.dumps({ + "claudeAiOauth": { + "accessToken": [redacted:secret-assignment] + "refreshToken": [redacted:secret-assignment] + "expiresAt": expires_at, + } + }), + stderr="", + ) + + def test_keychain_expired_file_fresh_returns_file(self, tmp_path, monkeypatch): + """Regression: when the Keychain holds an expired token but the JSON + file has a valid one, callers must receive the valid file token rather + than None. (Pre-fix behavior returned the expired Keychain token, and + downstream validity checks then yielded None — surfacing the misleading + ``No Anthropic credentials found`` error.) + """ + self._setup(tmp_path, monkeypatch, file_expires_at=self._FRESH, file_token="[redacted:secret-assignment]") + with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ + patch("agent.anthropic_adapter.subprocess.run") as mock_run: + mock_run.return_value = self._keychain_payload( + access_token="[redacted:secret-assignment]", expires_at=self._EXPIRED, + ) + creds = read_claude_code_credentials() + + assert creds is not None + assert creds["accessToken"] == "fresh-file-token" + assert creds["source"] == "claude_code_credentials_file" + + def test_keychain_fresh_file_expired_returns_keychain(self, tmp_path, monkeypatch): + """Mirror case: file is the stale source; Keychain wins on validity.""" + self._setup(tmp_path, monkeypatch, file_expires_at=self._EXPIRED, file_token="[redacted:secret-assignment]") + with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ + patch("agent.anthropic_adapter.subprocess.run") as mock_run: + mock_run.return_value = self._keychain_payload( + access_token="[redacted:secret-assignment]", expires_at=self._FRESH, + ) + creds = read_claude_code_credentials() + + assert creds is not None + assert creds["accessToken"] == "fresh-keychain-token" + assert creds["source"] == "macos_keychain" + + def test_both_valid_prefers_later_expiry_when_file_is_fresher(self, tmp_path, monkeypatch): + """When both are valid, the one with the later ``expiresAt`` wins so + that any subsequent refresh uses the freshest ``refresh_token``. + """ + self._setup(tmp_path, monkeypatch, file_expires_at=self._FRESH, file_token="[redacted:secret-assignment]") + with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ + patch("agent.anthropic_adapter.subprocess.run") as mock_run: + mock_run.return_value = self._keychain_payload( + access_token="[redacted:secret-assignment]", expires_at=self._FRESH - 1_000_000, + ) + creds = read_claude_code_credentials() + + assert creds is not None + assert creds["accessToken"] == "newer-file-token" + + def test_both_expired_prefers_later_expiry(self, tmp_path, monkeypatch): + """When both are expired, return the one with the later ``expiresAt``; + its ``refresh_token`` is the most recently issued and most likely to + succeed at the OAuth refresh endpoint. + """ + self._setup(tmp_path, monkeypatch, file_expires_at=self._EXPIRED + 5, file_token="[redacted:secret-assignment]") + with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ + patch("agent.anthropic_adapter.subprocess.run") as mock_run: + mock_run.return_value = self._keychain_payload( + access_token="[redacted:secret-assignment]", expires_at=self._EXPIRED, + ) + creds = read_claude_code_credentials() + + assert creds is not None + assert creds["accessToken"] == "newer-expired-file" + + +class TestRefreshOAuthTokenAdoptsFreshCredential: + """``_refresh_oauth_token`` should adopt a credential Claude Code has + already refreshed rather than POSTing a (possibly already-rotated) + single-use refresh token and racing Claude Code into ``invalid_grant``. + """ + + _FRESH = 9_999_999_999_999 + + def test_adopts_already_refreshed_token_without_posting(self, monkeypatch): + """When a live source already holds a valid token, return it and skip + the network refresh entirely. + """ + fresh = { + "accessToken": "[redacted:secret-assignment]", + "refreshToken": "[redacted:secret-assignment]", + "expiresAt": self._FRESH, + } + monkeypatch.setattr( + "agent.anthropic_adapter.read_claude_code_credentials", + lambda: fresh, + ) + + def _should_not_be_called(*args, **kwargs): # pragma: no cover - guard + raise AssertionError("refresh_anthropic_oauth_pure must not be called") + + monkeypatch.setattr( + "agent.anthropic_adapter.refresh_anthropic_oauth_pure", + _should_not_be_called, + ) + + # Stale creds passed in by the caller — should be ignored in favor + # of the live, already-refreshed token. + result = _refresh_oauth_token({"refreshToken": "stale", "expiresAt": 1}) + assert result == "already-refreshed-token" + + def test_falls_back_to_network_refresh_when_no_fresh_credential(self, monkeypatch): + """When no live source has a valid token, fall back to refreshing + ourselves using the freshest available refresh token. + """ + # Live read returns an expired credential carrying a refresh token. + monkeypatch.setattr( + "agent.anthropic_adapter.read_claude_code_credentials", + lambda: {"accessToken": "[redacted:secret-assignment]", "refreshToken": "[redacted:secret-assignment]", "expiresAt": 1}, + ) + captured = {} + + def _fake_refresh(refresh_token, **kwargs): + captured["refresh_token"] = refresh_token + return { + "access_token": "[redacted:secret-assignment]", + "refresh_token": "[redacted:secret-assignment]", + "expires_at_ms": self._FRESH, + } + + monkeypatch.setattr( + "agent.anthropic_adapter.refresh_anthropic_oauth_pure", _fake_refresh + ) + monkeypatch.setattr( + "agent.anthropic_adapter._write_claude_code_credentials", + lambda *a, **k: None, + ) + + result = _refresh_oauth_token({"refreshToken": "[redacted:secret-assignment]", "expiresAt": 1}) + assert result == "newly-minted" + # Prefers the live source's refresh token over the caller's stale copy. + assert captured["refresh_token"] == "live-refresh" +
-
2 PRs closed without merging
From the close comments: merged via another change 2
Closed issues · 1
- Closed#21107Anthropic auth fails when macOS Keychain and ~/.claude/.credentials.json hold different OAuth tokens
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #53899
- Closed issues
- 1
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 28 Jun 2026