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>
115 lines
3.6 KiB
Python
115 lines
3.6 KiB
Python
"""Host Claude auth helpers.
|
|
|
|
Reads the host's Claude Code credentials and returns only the access
|
|
token needed by egress. Does not expose refresh tokens or raw payloads.
|
|
|
|
Credential storage by platform:
|
|
Linux — ~/.claude/.credentials.json
|
|
macOS — macOS Keychain, service "Claude Code-credentials"
|
|
(file path is tried first; Keychain is the fallback)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from ...log import die
|
|
|
|
|
|
_KEYCHAIN_SERVICE = "Claude Code-credentials"
|
|
|
|
|
|
def claude_auth_path(host_env: dict[str, str] | None = None) -> Path:
|
|
env = os.environ if host_env is None else host_env
|
|
home = env.get("HOME")
|
|
if home:
|
|
return Path(home) / ".claude" / ".credentials.json"
|
|
return Path.home() / ".claude" / ".credentials.json"
|
|
|
|
|
|
def _read_keychain() -> dict[str, object] | None:
|
|
"""Try the macOS Keychain. Returns parsed JSON dict or None."""
|
|
if sys.platform != "darwin":
|
|
return None
|
|
try:
|
|
result = subprocess.run(
|
|
["security", "find-generic-password", "-s", _KEYCHAIN_SERVICE, "-w"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
return None
|
|
if result.returncode != 0 or not result.stdout.strip():
|
|
return None
|
|
try:
|
|
raw = json.loads(result.stdout.strip())
|
|
except json.JSONDecodeError:
|
|
return None
|
|
return raw if isinstance(raw, dict) else None
|
|
|
|
|
|
def claude_host_access_token(
|
|
host_env: dict[str, str] | None = None,
|
|
*,
|
|
now: datetime | None = None,
|
|
) -> str:
|
|
path = claude_auth_path(host_env)
|
|
raw: dict[str, object] | None = None
|
|
|
|
if path.is_file():
|
|
try:
|
|
raw = json.loads(path.read_text())
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
die(f"claude host credentials: could not read valid JSON at {path}: {e}")
|
|
if not isinstance(raw, dict):
|
|
die(f"claude host credentials: {path} must contain a JSON object")
|
|
else:
|
|
raw = _read_keychain()
|
|
if raw is None:
|
|
die(
|
|
f"claude host credentials: auth file missing at {path} and "
|
|
f"macOS Keychain lookup for '{_KEYCHAIN_SERVICE}' failed. "
|
|
"Run `claude login` on the host or disable "
|
|
"agent_provider.forward_host_credentials."
|
|
)
|
|
|
|
oauth = raw.get("claudeAiOauth")
|
|
if not isinstance(oauth, dict):
|
|
die(
|
|
"claude host credentials: claudeAiOauth is missing from credentials. "
|
|
"Run `claude login` on the host or disable "
|
|
"agent_provider.forward_host_credentials."
|
|
)
|
|
|
|
access_token = oauth.get("accessToken")
|
|
if not isinstance(access_token, str) or not access_token:
|
|
die(
|
|
"claude host credentials: claudeAiOauth.accessToken is missing or empty. "
|
|
"Run `claude login` on the host and restart the bottle."
|
|
)
|
|
|
|
# expiresAt is in milliseconds
|
|
expires_at = oauth.get("expiresAt")
|
|
if isinstance(expires_at, (int, float)):
|
|
check_now = now or datetime.now(timezone.utc)
|
|
exp_dt = datetime.fromtimestamp(float(expires_at) / 1000.0, timezone.utc)
|
|
if exp_dt <= check_now:
|
|
die(
|
|
"claude host credentials: host Claude access token is expired. "
|
|
"Run `claude login` on the host and restart the bottle."
|
|
)
|
|
|
|
return access_token
|
|
|
|
|
|
__all__ = [
|
|
"claude_auth_path",
|
|
"claude_host_access_token",
|
|
]
|