fix(claude): fall back to macOS Keychain for credentials
lint / lint (push) Failing after 2m10s
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m6s

On macOS, Claude Code stores credentials in the Keychain under
service "Claude Code-credentials" rather than in a file. When
~/.claude/.credentials.json is absent, shell out to:
  security find-generic-password -s "Claude Code-credentials" -w
and parse the result as the same JSON schema.

~/.claude.json holds only profile/UI metadata (oauthAccount has
no token fields). expiresAt in the credentials is milliseconds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 21:46:53 +00:00
parent bdd352570b
commit 7278ee1157
3 changed files with 166 additions and 60 deletions
+91 -30
View File
@@ -3,10 +3,12 @@
from __future__ import annotations
import json
import subprocess
import tempfile
import unittest
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.contrib.claude.claude_auth import (
claude_auth_path,
@@ -15,6 +17,11 @@ from bot_bottle.contrib.claude.claude_auth import (
from bot_bottle.log import Die
def _cred_json(access_token: str, **extra) -> str: # type: ignore[no-untyped-def]
payload: dict = {"claudeAiOauth": {"accessToken": access_token, **extra}}
return json.dumps(payload)
class TestClaudeHostAccessToken(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="bb-claude-auth.")
@@ -26,7 +33,7 @@ class TestClaudeHostAccessToken(unittest.TestCase):
def tearDown(self):
self.tmp.cleanup()
def _write(self, payload: dict) -> None: # type: ignore
def _write(self, payload: dict) -> None: # type: ignore[no-untyped-def]
self.auth_path.write_text(json.dumps(payload))
def test_auth_path_uses_home_env(self):
@@ -35,41 +42,33 @@ class TestClaudeHostAccessToken(unittest.TestCase):
claude_auth_path({"HOME": str(self.home)}),
)
def test_returns_access_token(self):
# --- file-based (Linux) ---
def test_file_returns_access_token(self):
key = "sk-ant-oat01-real-key"
self._write({"claudeAiOauth": {"accessToken": key}})
out = claude_host_access_token({"HOME": str(self.home)})
self.assertEqual(key, out)
def test_missing_auth_file_dies(self):
with self.assertRaises(Die):
# Use a home with no .credentials.json
empty_home = self.home / "empty"
empty_home.mkdir()
claude_host_access_token({"HOME": str(empty_home)})
def test_missing_claude_ai_oauth_dies(self):
def test_file_missing_claude_ai_oauth_dies(self):
self._write({"hasCompletedOnboarding": True})
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)})
def test_missing_access_token_dies(self):
def test_file_missing_access_token_dies(self):
self._write({"claudeAiOauth": {"expiresAt": 2000000000000}})
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)})
def test_empty_access_token_dies(self):
def test_file_empty_access_token_dies(self):
self._write({"claudeAiOauth": {"accessToken": ""}})
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)})
def test_expired_token_dies(self):
# expiresAt is in milliseconds; 1000000 ms = year 1970
def test_file_expired_token_dies(self):
# expiresAt is milliseconds; 1_000_000 ms is year 1970
self._write({
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-x",
"expiresAt": 1000000,
},
"claudeAiOauth": {"accessToken": "sk-ant-oat01-x", "expiresAt": 1_000_000},
})
with self.assertRaises(Die):
claude_host_access_token(
@@ -77,14 +76,11 @@ class TestClaudeHostAccessToken(unittest.TestCase):
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
def test_future_expiry_is_accepted(self):
def test_file_future_expiry_is_accepted(self):
key = "sk-ant-oat01-y"
# 2000000000000 ms = ~year 2033
# 2_000_000_000_000 ms year 2033
self._write({
"claudeAiOauth": {
"accessToken": key,
"expiresAt": 2000000000000,
},
"claudeAiOauth": {"accessToken": key, "expiresAt": 2_000_000_000_000},
})
out = claude_host_access_token(
{"HOME": str(self.home)},
@@ -92,35 +88,100 @@ class TestClaudeHostAccessToken(unittest.TestCase):
)
self.assertEqual(key, out)
def test_absent_expiry_field_is_accepted(self):
def test_file_absent_expiry_is_accepted(self):
key = "sk-ant-oat01-z"
self._write({"claudeAiOauth": {"accessToken": key}})
out = claude_host_access_token({"HOME": str(self.home)})
self.assertEqual(key, out)
def test_non_json_file_dies(self):
def test_file_non_json_dies(self):
self.auth_path.write_text("not json {{{")
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)})
def test_json_array_root_dies(self):
def test_file_json_array_root_dies(self):
self.auth_path.write_text("[]")
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)})
def test_extra_fields_in_credentials_are_ignored(self):
def test_file_extra_fields_are_ignored(self):
key = "sk-ant-oat01-real"
self._write({
"claudeAiOauth": {
"accessToken": key,
"refreshToken": "sk-ant-ort01-secret",
"scopes": ["user:inference", "user:profile"],
"expiresAt": 2000000000000,
"scopes": ["user:inference"],
"expiresAt": 2_000_000_000_000,
},
})
out = claude_host_access_token({"HOME": str(self.home)})
self.assertEqual(key, out)
# --- macOS Keychain fallback ---
def _home_without_creds(self) -> Path:
"""A home dir that has .claude/ but no .credentials.json."""
empty = self.home / "no-creds"
(empty / ".claude").mkdir(parents=True)
return empty
def _mock_keychain(self, stdout: str, returncode: int = 0) -> MagicMock:
mock = MagicMock()
mock.returncode = returncode
mock.stdout = stdout
return mock
def test_keychain_used_when_file_absent(self):
key = "sk-ant-oat01-keychain"
home = self._home_without_creds()
with patch(
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
return_value=self._mock_keychain(_cred_json(key)),
), patch(
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
):
out = claude_host_access_token({"HOME": str(home)})
self.assertEqual(key, out)
def test_keychain_failure_when_file_absent_dies(self):
home = self._home_without_creds()
with patch(
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
return_value=self._mock_keychain("", returncode=44),
), patch(
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
):
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(home)})
def test_no_file_no_keychain_on_linux_dies(self):
home = self._home_without_creds()
with patch("bot_bottle.contrib.claude.claude_auth.sys.platform", "linux"):
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(home)})
def test_keychain_non_json_dies(self):
home = self._home_without_creds()
with patch(
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
return_value=self._mock_keychain("not-json"),
), patch(
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
):
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(home)})
def test_keychain_security_not_found_dies(self):
home = self._home_without_creds()
with patch(
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
side_effect=FileNotFoundError,
), patch(
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
):
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(home)})
if __name__ == "__main__":
unittest.main()