7278ee1157
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>
188 lines
6.5 KiB
Python
188 lines
6.5 KiB
Python
"""Unit: host Claude auth extraction."""
|
|
|
|
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,
|
|
claude_host_access_token,
|
|
)
|
|
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.")
|
|
self.home = Path(self.tmp.name)
|
|
self.cred_dir = self.home / ".claude"
|
|
self.cred_dir.mkdir()
|
|
self.auth_path = self.cred_dir / ".credentials.json"
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
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):
|
|
self.assertEqual(
|
|
self.auth_path,
|
|
claude_auth_path({"HOME": str(self.home)}),
|
|
)
|
|
|
|
# --- 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_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_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_file_empty_access_token_dies(self):
|
|
self._write({"claudeAiOauth": {"accessToken": ""}})
|
|
with self.assertRaises(Die):
|
|
claude_host_access_token({"HOME": str(self.home)})
|
|
|
|
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": 1_000_000},
|
|
})
|
|
with self.assertRaises(Die):
|
|
claude_host_access_token(
|
|
{"HOME": str(self.home)},
|
|
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
)
|
|
|
|
def test_file_future_expiry_is_accepted(self):
|
|
key = "sk-ant-oat01-y"
|
|
# 2_000_000_000_000 ms ≈ year 2033
|
|
self._write({
|
|
"claudeAiOauth": {"accessToken": key, "expiresAt": 2_000_000_000_000},
|
|
})
|
|
out = claude_host_access_token(
|
|
{"HOME": str(self.home)},
|
|
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
)
|
|
self.assertEqual(key, out)
|
|
|
|
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_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_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_file_extra_fields_are_ignored(self):
|
|
key = "sk-ant-oat01-real"
|
|
self._write({
|
|
"claudeAiOauth": {
|
|
"accessToken": key,
|
|
"refreshToken": "sk-ant-ort01-secret",
|
|
"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()
|