feat(backend): add Linux secret-service support for OAuth token storage (ACS-293) (#1168)
* feat(backend): add Linux secret-service support for OAuth token storage (ACS-293) This commit implements Linux keychain support using the secretstorage library, bringing Linux to parity with macOS (Keychain) and Windows (Credential Files). Changes: - Add _get_token_from_linux_secret_service() function in auth.py - Uses secretstorage library for DBus communication - Searches for Claude Code credentials by application attribute - Validates token format (sk-ant-oat01- prefix) - Graceful fallback to .env when secret-service unavailable - Update get_token_from_keychain() to call Linux implementation - Update get_auth_token_source() to return "Linux Secret Service" - Update require_auth_token() error message with Linux instructions - Add secretstorage>=3.3.3 to requirements.txt (Linux-only) Testing: - Add comprehensive test suite in tests/test_auth.py (38 tests) - Environment variable token resolution - macOS keychain token retrieval - Windows credential file token retrieval - Linux secret-service token retrieval (new) - Token source detection - Error handling and edge cases Fixes ACS-293 * fix(tests): remove unused 'patch' import from test_auth.py * fix(auth): address PR review feedback for Linux secret-service support CRITICAL fixes: - Fix inverted lock check logic - unlock when collection.is_locked() is True - Fix missing connection argument - pass None to get_default_collection() HIGH priority: - Use exact label matching (==) instead of substring match (in) to avoid false positives with similar credential names MEDIUM priority: - Replace broad Exception catch with specific exception types Test improvements: - Remove unused import core.auth as auth_module (4 instances) - Remove unused mock_secretstorage fixture - Use monkeypatch.setenv() instead of direct os.environ modification - Add test_linux_secret_service_exact_label_match_only() to verify exact matching behavior All 39 tests passing. Addresses review comments on PR #1168 Refs: ACS-293 --------- Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
This commit is contained in:
@@ -10,6 +10,17 @@ import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
# Optional import for Linux secret-service support
|
||||
# secretstorage provides access to the Freedesktop.org Secret Service API via DBus
|
||||
if TYPE_CHECKING:
|
||||
import secretstorage
|
||||
else:
|
||||
try:
|
||||
import secretstorage # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
secretstorage = None # type: ignore[assignment]
|
||||
|
||||
# Priority order for auth token resolution
|
||||
# NOTE: We intentionally do NOT fall back to ANTHROPIC_API_KEY.
|
||||
@@ -48,7 +59,7 @@ def get_token_from_keychain() -> str | None:
|
||||
Reads Claude Code credentials from:
|
||||
- macOS: Keychain
|
||||
- Windows: Credential Manager
|
||||
- Linux: Not yet supported (use env var)
|
||||
- Linux: Secret Service API (via dbus/secretstorage)
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
@@ -60,8 +71,8 @@ def get_token_from_keychain() -> str | None:
|
||||
elif system == "Windows":
|
||||
return _get_token_from_windows_credential_files()
|
||||
else:
|
||||
# Linux: secret-service not yet implemented
|
||||
return None
|
||||
# Linux: use secret-service API via DBus
|
||||
return _get_token_from_linux_secret_service()
|
||||
|
||||
|
||||
def _get_token_from_macos_keychain() -> str | None:
|
||||
@@ -131,6 +142,82 @@ def _get_token_from_windows_credential_files() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _get_token_from_linux_secret_service() -> str | None:
|
||||
"""Get token from Linux Secret Service API via DBus.
|
||||
|
||||
Claude Code on Linux stores credentials in the Secret Service API
|
||||
using the 'org.freedesktop.secrets' collection. This implementation
|
||||
uses the secretstorage library which communicates via DBus.
|
||||
|
||||
The credential is stored with:
|
||||
- Label: "Claude Code-credentials"
|
||||
- Attributes: {application: "claude-code"}
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
if secretstorage is None:
|
||||
# secretstorage not installed, fall back to env var
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get the default collection (typically "login" keyring)
|
||||
# secretstorage handles DBus communication internally
|
||||
try:
|
||||
collection = secretstorage.get_default_collection(None)
|
||||
except (
|
||||
AttributeError,
|
||||
secretstorage.exceptions.SecretServiceNotAvailableException,
|
||||
):
|
||||
# DBus not available or secret-service not running
|
||||
return None
|
||||
|
||||
if collection.is_locked():
|
||||
# Try to unlock the collection (may prompt user for password)
|
||||
try:
|
||||
collection.unlock()
|
||||
except secretstorage.exceptions.SecretStorageException:
|
||||
# User cancelled or unlock failed
|
||||
return None
|
||||
|
||||
# Search for items with our application attribute
|
||||
items = collection.search_items({"application": "claude-code"})
|
||||
|
||||
for item in items:
|
||||
# Check if this is the Claude Code credentials item
|
||||
label = item.get_label()
|
||||
# Use exact match for "Claude Code-credentials" to avoid false positives
|
||||
if label == "Claude Code-credentials":
|
||||
# Get the secret (stored as JSON string)
|
||||
secret = item.get_secret()
|
||||
if not secret:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Explicitly decode bytes to string if needed
|
||||
if isinstance(secret, bytes):
|
||||
secret = secret.decode("utf-8")
|
||||
data = json.loads(secret)
|
||||
token = data.get("claudeAiOauth", {}).get("accessToken")
|
||||
|
||||
if token and token.startswith("sk-ant-oat01-"):
|
||||
return token
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
except (
|
||||
secretstorage.exceptions.SecretStorageException,
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
AttributeError,
|
||||
TypeError,
|
||||
):
|
||||
# Any error with secret-service, fall back to env var
|
||||
return None
|
||||
|
||||
|
||||
def get_auth_token() -> str | None:
|
||||
"""
|
||||
Get authentication token from environment variables or system credential store.
|
||||
@@ -138,7 +225,7 @@ def get_auth_token() -> str | None:
|
||||
Checks multiple sources in priority order:
|
||||
1. CLAUDE_CODE_OAUTH_TOKEN (env var)
|
||||
2. ANTHROPIC_AUTH_TOKEN (CCR/proxy env var for enterprise setups)
|
||||
3. System credential store (macOS Keychain, Windows Credential Manager)
|
||||
3. System credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service)
|
||||
|
||||
NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent
|
||||
silent billing to user's API credits when OAuth is misconfigured.
|
||||
@@ -171,7 +258,7 @@ def get_auth_token_source() -> str | None:
|
||||
elif system == "Windows":
|
||||
return "Windows Credential Files"
|
||||
else:
|
||||
return "System Credential Store"
|
||||
return "Linux Secret Service"
|
||||
|
||||
return None
|
||||
|
||||
@@ -208,10 +295,12 @@ def require_auth_token() -> str:
|
||||
"Check: %LOCALAPPDATA%\\Claude\\credentials.json"
|
||||
)
|
||||
else:
|
||||
# Linux
|
||||
error_msg += (
|
||||
"To authenticate:\n"
|
||||
" 1. Run: claude setup-token\n"
|
||||
" 2. Set CLAUDE_CODE_OAUTH_TOKEN in your .env file"
|
||||
" 2. The token will be saved to the system secret service (gnome-keyring/kwallet)\n\n"
|
||||
"If secret-service is not available, set CLAUDE_CODE_OAUTH_TOKEN in your .env file."
|
||||
)
|
||||
raise ValueError(error_msg)
|
||||
return token
|
||||
|
||||
@@ -5,6 +5,11 @@ python-dotenv>=1.0.0
|
||||
# TOML parsing fallback for Python < 3.11
|
||||
tomli>=2.0.0; python_version < "3.11"
|
||||
|
||||
# Linux Secret Service support for credential storage
|
||||
# Provides access to the Freedesktop.org Secret Service API via DBus
|
||||
# Used on Linux to store OAuth tokens in gnome-keyring/kwallet
|
||||
secretstorage>=3.3.3; sys_platform == "linux"
|
||||
|
||||
# Memory Integration - LadybugDB (embedded graph database)
|
||||
# Requires Python 3.12+ (no Docker required)
|
||||
real_ladybug>=0.13.0; python_version >= "3.12"
|
||||
|
||||
+572
-93
@@ -1,129 +1,608 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for core/auth module.
|
||||
Tests for Authentication System
|
||||
================================
|
||||
|
||||
Tests authentication token management and SDK environment variable handling,
|
||||
including the PYTHONPATH isolation fix for ACS-251.
|
||||
Tests the auth.py module functionality including:
|
||||
- Environment variable token resolution
|
||||
- System credential store integration (macOS, Windows, Linux)
|
||||
- Token source detection
|
||||
- Token validation and format checking
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
from core.auth import get_sdk_env_vars
|
||||
import pytest
|
||||
from core.auth import (
|
||||
AUTH_TOKEN_ENV_VARS,
|
||||
ensure_claude_code_oauth_token,
|
||||
get_auth_token,
|
||||
get_auth_token_source,
|
||||
get_sdk_env_vars,
|
||||
get_token_from_keychain,
|
||||
require_auth_token,
|
||||
)
|
||||
|
||||
|
||||
class TestGetSdkEnvVars:
|
||||
"""Tests for get_sdk_env_vars() function."""
|
||||
class TestEnvVarTokenResolution:
|
||||
"""Tests for environment variable token resolution."""
|
||||
|
||||
def test_pythonpath_is_always_set_in_result(self, monkeypatch):
|
||||
"""
|
||||
PYTHONPATH should always be present in result, even when empty.
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_env(self):
|
||||
"""Clear auth environment variables before each test."""
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
os.environ.pop(var, None)
|
||||
yield
|
||||
# Cleanup after test
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
os.environ.pop(var, None)
|
||||
|
||||
When no SDK env vars are set, PYTHONPATH is still explicitly set to
|
||||
empty string to override any inherited value from the parent process.
|
||||
"""
|
||||
# Clear all SDK env vars
|
||||
for var in [
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_MODEL",
|
||||
"NO_PROXY",
|
||||
"DISABLE_TELEMETRY",
|
||||
]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
def test_claude_oauth_token_from_env(self):
|
||||
"""Reads CLAUDE_CODE_OAUTH_TOKEN from environment."""
|
||||
test_token = "sk-ant-oat01-test-token"
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = test_token
|
||||
|
||||
result = get_sdk_env_vars()
|
||||
token = get_auth_token()
|
||||
assert token == test_token
|
||||
|
||||
# PYTHONPATH should always be present, even if empty
|
||||
assert "PYTHONPATH" in result
|
||||
assert result["PYTHONPATH"] == ""
|
||||
def test_anthropic_auth_token_from_env(self):
|
||||
"""Reads ANTHROPIC_AUTH_TOKEN from environment."""
|
||||
test_token = "sk-ant-oat01-test-enterprise-token"
|
||||
os.environ["ANTHROPIC_AUTH_TOKEN"] = test_token
|
||||
|
||||
def test_includes_anthropic_base_url_when_set(self, monkeypatch):
|
||||
"""Should include ANTHROPIC_BASE_URL when set."""
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.example.com")
|
||||
token = get_auth_token()
|
||||
assert token == test_token
|
||||
|
||||
result = get_sdk_env_vars()
|
||||
def test_claude_oauth_takes_precedence(self):
|
||||
"""CLAUDE_CODE_OAUTH_TOKEN takes precedence over ANTHROPIC_AUTH_TOKEN."""
|
||||
claude_token = "sk-ant-oat01-claude-token"
|
||||
anthropic_token = "sk-ant-oat01-anthropic-token"
|
||||
|
||||
assert result.get("ANTHROPIC_BASE_URL") == "https://api.example.com"
|
||||
os.environ["ANTHROPIC_AUTH_TOKEN"] = anthropic_token
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = claude_token
|
||||
|
||||
def test_includes_anthropic_auth_token_when_set(self, monkeypatch):
|
||||
"""Should include ANTHROPIC_AUTH_TOKEN when set."""
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "sk-test-token")
|
||||
token = get_auth_token()
|
||||
assert token == claude_token
|
||||
|
||||
result = get_sdk_env_vars()
|
||||
def test_no_token_returns_none(self):
|
||||
"""Returns None when no auth token is configured."""
|
||||
token = get_auth_token()
|
||||
assert token is None
|
||||
|
||||
assert result.get("ANTHROPIC_AUTH_TOKEN") == "sk-test-token"
|
||||
def test_token_source_from_env(self):
|
||||
"""Identifies environment variable as token source."""
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = "sk-ant-oat01-test-token"
|
||||
|
||||
def test_pythonpath_isolation_from_parent_process(self, monkeypatch):
|
||||
"""
|
||||
Test ACS-251 fix: PYTHONPATH from parent process should be overridden.
|
||||
source = get_auth_token_source()
|
||||
assert source == "CLAUDE_CODE_OAUTH_TOKEN"
|
||||
|
||||
This ensures that Auto-Claude's PYTHONPATH (which may point to Python 3.12
|
||||
packages) doesn't pollute agent subprocess environments, preventing
|
||||
failures when working on external projects with different Python versions.
|
||||
"""
|
||||
# Simulate parent process having a PYTHONPATH set
|
||||
monkeypatch.setenv(
|
||||
"PYTHONPATH",
|
||||
"/path/to/auto-claude/backend:/path/to/python3.12/site-packages",
|
||||
def test_empty_token_ignored(self):
|
||||
"""Empty string tokens are ignored."""
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = ""
|
||||
os.environ["ANTHROPIC_AUTH_TOKEN"] = "sk-ant-oat01-test-token"
|
||||
|
||||
token = get_auth_token()
|
||||
# Should get ANTHROPIC_AUTH_TOKEN since CLAUDE_CODE_OAUTH_TOKEN is empty
|
||||
assert token == "sk-ant-oat01-test-token"
|
||||
|
||||
|
||||
class TestMacOSKeychain:
|
||||
"""Tests for macOS keychain token retrieval."""
|
||||
|
||||
def test_macos_keychain_success(self, monkeypatch):
|
||||
"""Successfully retrieves token from macOS keychain."""
|
||||
test_token = "sk-ant-oat01-macos-token"
|
||||
credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}})
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = credentials
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result))
|
||||
|
||||
token = get_token_from_keychain()
|
||||
assert token == test_token
|
||||
|
||||
def test_macos_keychain_command_failure(self, monkeypatch):
|
||||
"""Returns None when security command fails."""
|
||||
mock_result = Mock()
|
||||
mock_result.returncode = 1
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result))
|
||||
|
||||
token = get_token_from_keychain()
|
||||
assert token is None
|
||||
|
||||
def test_macos_keychain_invalid_json(self, monkeypatch):
|
||||
"""Returns None when keychain returns invalid JSON."""
|
||||
mock_result = Mock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "invalid json"
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result))
|
||||
|
||||
token = get_token_from_keychain()
|
||||
assert token is None
|
||||
|
||||
def test_macos_keychain_invalid_token_format(self, monkeypatch):
|
||||
"""Returns None when token doesn't start with sk-ant-oat01-."""
|
||||
credentials = json.dumps({"claudeAiOauth": {"accessToken": "invalid-token"}})
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = credentials
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result))
|
||||
|
||||
token = get_token_from_keychain()
|
||||
assert token is None
|
||||
|
||||
|
||||
class TestWindowsCredentialFiles:
|
||||
"""Tests for Windows credential file token retrieval."""
|
||||
|
||||
def test_windows_credential_file_success(self, monkeypatch, tmp_path):
|
||||
"""Successfully retrieves token from Windows credential file."""
|
||||
test_token = "sk-ant-oat01-windows-token"
|
||||
credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}})
|
||||
|
||||
# Create a temporary credential file
|
||||
cred_file = tmp_path / ".credentials.json"
|
||||
cred_file.write_text(credentials)
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
||||
monkeypatch.setattr(
|
||||
os.path, "expandvars", lambda p: str(cred_file).replace("\\", "/")
|
||||
)
|
||||
|
||||
result = get_sdk_env_vars()
|
||||
token = get_token_from_keychain()
|
||||
assert token == test_token
|
||||
|
||||
# PYTHONPATH should be explicitly overridden to empty string
|
||||
# This prevents the SDK from inheriting the parent's PYTHONPATH
|
||||
assert "PYTHONPATH" in result
|
||||
assert result["PYTHONPATH"] == ""
|
||||
def test_windows_credential_file_not_found(self, monkeypatch):
|
||||
"""Returns None when credential file doesn't exist."""
|
||||
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
||||
monkeypatch.setattr(os.path, "exists", lambda x: False)
|
||||
|
||||
def test_skips_empty_env_vars(self, monkeypatch):
|
||||
"""Should not include env vars with empty values."""
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", "")
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "")
|
||||
token = get_token_from_keychain()
|
||||
assert token is None
|
||||
|
||||
result = get_sdk_env_vars()
|
||||
def test_windows_credential_file_invalid_json(self, monkeypatch, tmp_path):
|
||||
"""Returns None when credential file contains invalid JSON."""
|
||||
cred_file = tmp_path / ".credentials.json"
|
||||
cred_file.write_text("invalid json")
|
||||
|
||||
# Empty vars should not be in result (except PYTHONPATH which is explicitly set)
|
||||
assert "ANTHROPIC_BASE_URL" not in result
|
||||
assert "ANTHROPIC_AUTH_TOKEN" not in result
|
||||
# PYTHONPATH should still be present as explicit override
|
||||
assert result["PYTHONPATH"] == ""
|
||||
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
||||
monkeypatch.setattr(
|
||||
os.path, "expandvars", lambda p: str(cred_file).replace("\\", "/")
|
||||
)
|
||||
monkeypatch.setattr(os.path, "exists", lambda x: str(x).endswith(".json"))
|
||||
|
||||
def test_on_windows_auto_detects_git_bash_path(self, monkeypatch):
|
||||
"""On Windows, should auto-detect git-bash path if not set."""
|
||||
# Mock platform.system to simulate Windows for cross-platform testing
|
||||
with patch.object(platform, "system", return_value="Windows"):
|
||||
# Mock _find_git_bash_path to return a path
|
||||
with patch(
|
||||
"core.auth._find_git_bash_path",
|
||||
return_value="C:/Program Files/Git/bin/bash.exe",
|
||||
):
|
||||
monkeypatch.delenv("CLAUDE_CODE_GIT_BASH_PATH", raising=False)
|
||||
token = get_token_from_keychain()
|
||||
assert token is None
|
||||
|
||||
result = get_sdk_env_vars()
|
||||
|
||||
assert (
|
||||
result.get("CLAUDE_CODE_GIT_BASH_PATH")
|
||||
== "C:/Program Files/Git/bin/bash.exe"
|
||||
)
|
||||
class TestLinuxSecretService:
|
||||
"""Tests for Linux Secret Service token retrieval."""
|
||||
|
||||
def test_preserves_existing_git_bash_path_on_windows(self, monkeypatch):
|
||||
"""On Windows, should preserve existing CLAUDE_CODE_GIT_BASH_PATH."""
|
||||
# Mock platform.system to simulate Windows for cross-platform testing
|
||||
with patch.object(platform, "system", return_value="Windows"):
|
||||
monkeypatch.setenv("CLAUDE_CODE_GIT_BASH_PATH", "C:/Custom/bash.exe")
|
||||
def test_linux_secret_service_not_installed(self, monkeypatch):
|
||||
"""Returns None when secretstorage is not installed."""
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr("core.auth.secretstorage", None)
|
||||
|
||||
result = get_sdk_env_vars()
|
||||
token = get_token_from_keychain()
|
||||
assert token is None
|
||||
|
||||
assert result.get("CLAUDE_CODE_GIT_BASH_PATH") == "C:/Custom/bash.exe"
|
||||
def test_linux_secret_service_dbus_not_available(self, monkeypatch):
|
||||
"""Returns None when DBus is not available."""
|
||||
mock_ss = MagicMock()
|
||||
mock_ss.exceptions = MagicMock()
|
||||
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
||||
|
||||
def test_on_non_windows_git_bash_not_added(self, monkeypatch):
|
||||
"""On non-Windows platforms, CLAUDE_CODE_GIT_BASH_PATH should not be auto-added."""
|
||||
# Mock platform.system to simulate Linux for cross-platform testing
|
||||
# When platform is not Windows, _find_git_bash_path is never called
|
||||
with patch.object(platform, "system", return_value="Linux"):
|
||||
monkeypatch.delenv("CLAUDE_CODE_GIT_BASH_PATH", raising=False)
|
||||
# Make get_default_collection raise exception
|
||||
mock_ss.get_default_collection.side_effect = Exception("DBus not available")
|
||||
|
||||
result = get_sdk_env_vars()
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
||||
|
||||
# Should not have CLAUDE_CODE_GIT_BASH_PATH
|
||||
assert "CLAUDE_CODE_GIT_BASH_PATH" not in result
|
||||
token = get_token_from_keychain()
|
||||
assert token is None
|
||||
|
||||
def test_linux_secret_service_success(self, monkeypatch):
|
||||
"""Successfully retrieves token from Linux secret service."""
|
||||
test_token = "sk-ant-oat01-linux-token"
|
||||
credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}})
|
||||
|
||||
# Mock secretstorage
|
||||
mock_ss = MagicMock()
|
||||
mock_ss.exceptions = MagicMock()
|
||||
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
||||
mock_ss.exceptions.SecretStorageException = Exception
|
||||
|
||||
# Mock collection
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.is_locked.return_value = False
|
||||
mock_collection.unlock.return_value = None
|
||||
|
||||
# Mock item
|
||||
mock_item = MagicMock()
|
||||
mock_item.get_label.return_value = "Claude Code-credentials"
|
||||
mock_item.get_secret.return_value = credentials
|
||||
mock_item.is_locked.return_value = False
|
||||
|
||||
mock_collection.search_items.return_value = [mock_item]
|
||||
mock_ss.get_default_collection.return_value = mock_collection
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
||||
|
||||
token = get_token_from_keychain()
|
||||
assert token == test_token
|
||||
|
||||
def test_linux_secret_service_exact_label_match_only(self, monkeypatch):
|
||||
"""Only matches exact 'Claude Code-credentials' label."""
|
||||
test_token = "sk-ant-oat01-linux-token"
|
||||
credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}})
|
||||
|
||||
mock_ss = MagicMock()
|
||||
mock_ss.exceptions = MagicMock()
|
||||
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
||||
mock_ss.exceptions.SecretStorageException = Exception
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.is_locked.return_value = False
|
||||
|
||||
# Mock item with similar but not exact label
|
||||
mock_item = MagicMock()
|
||||
mock_item.get_label.return_value = (
|
||||
"Some-Claude-Code-Thing" # Similar but not exact
|
||||
)
|
||||
mock_item.get_secret.return_value = credentials
|
||||
|
||||
mock_collection.search_items.return_value = [mock_item]
|
||||
mock_ss.get_default_collection.return_value = mock_collection
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
||||
|
||||
token = get_token_from_keychain()
|
||||
# Should return None because label doesn't match exactly
|
||||
assert token is None
|
||||
|
||||
def test_linux_secret_service_locked_collection_unlock_fails(self, monkeypatch):
|
||||
"""Returns None when collection is locked and unlock fails."""
|
||||
|
||||
mock_ss = MagicMock()
|
||||
mock_ss.exceptions = MagicMock()
|
||||
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
||||
mock_ss.exceptions.SecretStorageException = Exception
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.is_locked.return_value = True
|
||||
mock_collection.unlock.side_effect = Exception("Unlock failed")
|
||||
|
||||
mock_ss.get_default_collection.return_value = mock_collection
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
||||
|
||||
token = get_token_from_keychain()
|
||||
assert token is None
|
||||
|
||||
def test_linux_secret_service_no_matching_item(self, monkeypatch):
|
||||
"""Returns None when no matching credential found."""
|
||||
mock_ss = MagicMock()
|
||||
mock_ss.exceptions = MagicMock()
|
||||
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
||||
mock_ss.exceptions.SecretStorageException = Exception
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.is_locked.return_value = False
|
||||
mock_collection.search_items.return_value = [] # No items found
|
||||
|
||||
mock_ss.get_default_collection.return_value = mock_collection
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
||||
|
||||
token = get_token_from_keychain()
|
||||
assert token is None
|
||||
|
||||
def test_linux_secret_service_invalid_json(self, monkeypatch):
|
||||
"""Returns None when stored secret contains invalid JSON."""
|
||||
mock_ss = MagicMock()
|
||||
mock_ss.exceptions = MagicMock()
|
||||
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
||||
mock_ss.exceptions.SecretStorageException = Exception
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.is_locked.return_value = False
|
||||
|
||||
mock_item = MagicMock()
|
||||
mock_item.get_label.return_value = "Claude Code-credentials"
|
||||
mock_item.get_secret.return_value = "invalid json"
|
||||
|
||||
mock_collection.search_items.return_value = [mock_item]
|
||||
mock_ss.get_default_collection.return_value = mock_collection
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
||||
|
||||
token = get_token_from_keychain()
|
||||
assert token is None
|
||||
|
||||
def test_linux_secret_service_invalid_token_format(self, monkeypatch):
|
||||
"""Returns None when token doesn't start with sk-ant-oat01-."""
|
||||
credentials = json.dumps({"claudeAiOauth": {"accessToken": "invalid-token"}})
|
||||
|
||||
mock_ss = MagicMock()
|
||||
mock_ss.exceptions = MagicMock()
|
||||
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
||||
mock_ss.exceptions.SecretStorageException = Exception
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.is_locked.return_value = False
|
||||
|
||||
mock_item = MagicMock()
|
||||
mock_item.get_label.return_value = "Claude Code-credentials"
|
||||
mock_item.get_secret.return_value = credentials
|
||||
|
||||
mock_collection.search_items.return_value = [mock_item]
|
||||
mock_ss.get_default_collection.return_value = mock_collection
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
||||
|
||||
token = get_token_from_keychain()
|
||||
assert token is None
|
||||
|
||||
|
||||
class TestRequireAuthToken:
|
||||
"""Tests for require_auth_token function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_env(self):
|
||||
"""Clear auth environment variables before each test."""
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
os.environ.pop(var, None)
|
||||
yield
|
||||
# Cleanup after test
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
os.environ.pop(var, None)
|
||||
|
||||
def test_require_token_returns_valid_token(self):
|
||||
"""Returns token when valid token exists."""
|
||||
test_token = "sk-ant-oat01-test-token"
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = test_token
|
||||
|
||||
token = require_auth_token()
|
||||
assert token == test_token
|
||||
|
||||
def test_require_token_raises_when_missing(self):
|
||||
"""Raises ValueError when no token is configured."""
|
||||
with pytest.raises(ValueError, match="No OAuth token found"):
|
||||
require_auth_token()
|
||||
|
||||
def test_error_message_includes_macos_instructions(self, monkeypatch):
|
||||
"""Error message includes macOS setup instructions."""
|
||||
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
require_auth_token()
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "macOS Keychain" in error_msg
|
||||
assert "claude setup-token" in error_msg
|
||||
|
||||
def test_error_message_includes_windows_instructions(self, monkeypatch):
|
||||
"""Error message includes Windows setup instructions."""
|
||||
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
require_auth_token()
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "Windows Credential Manager" in error_msg
|
||||
assert "claude setup-token" in error_msg
|
||||
|
||||
def test_error_message_includes_linux_instructions(self, monkeypatch):
|
||||
"""Error message includes Linux setup instructions."""
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
require_auth_token()
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "secret service" in error_msg.lower()
|
||||
assert "gnome-keyring" in error_msg.lower()
|
||||
assert "claude setup-token" in error_msg
|
||||
|
||||
|
||||
class TestEnsureClaudeCodeOAuthToken:
|
||||
"""Tests for ensure_claude_code_oauth_token function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_env(self):
|
||||
"""Clear auth environment variables before each test."""
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
os.environ.pop(var, None)
|
||||
os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None)
|
||||
yield
|
||||
# Cleanup after test
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
os.environ.pop(var, None)
|
||||
os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None)
|
||||
|
||||
def test_does_nothing_when_already_set(self):
|
||||
"""Doesn't modify env var when CLAUDE_CODE_OAUTH_TOKEN is already set."""
|
||||
existing_token = "sk-ant-oat01-existing-token"
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = existing_token
|
||||
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
assert os.environ["CLAUDE_CODE_OAUTH_TOKEN"] == existing_token
|
||||
|
||||
def test_copies_from_anthropic_auth_token(self):
|
||||
"""Copies ANTHROPIC_AUTH_TOKEN to CLAUDE_CODE_OAUTH_TOKEN."""
|
||||
anthropic_token = "sk-ant-oat01-anthropic-token"
|
||||
os.environ["ANTHROPIC_AUTH_TOKEN"] = anthropic_token
|
||||
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
assert os.environ["CLAUDE_CODE_OAUTH_TOKEN"] == anthropic_token
|
||||
|
||||
def test_does_nothing_when_no_token_available(self, monkeypatch):
|
||||
"""Doesn't set env var when no auth token is available."""
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
# Ensure keychain returns None
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
assert "CLAUDE_CODE_OAUTH_TOKEN" not in os.environ
|
||||
|
||||
|
||||
class TestTokenSourceDetection:
|
||||
"""Tests for get_auth_token_source function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_env(self):
|
||||
"""Clear auth environment variables before each test."""
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
os.environ.pop(var, None)
|
||||
yield
|
||||
# Cleanup after test
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
os.environ.pop(var, None)
|
||||
|
||||
def test_source_env_var_claude_oauth(self):
|
||||
"""Identifies CLAUDE_CODE_OAUTH_TOKEN as source."""
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = "sk-ant-oat01-test-token"
|
||||
|
||||
source = get_auth_token_source()
|
||||
assert source == "CLAUDE_CODE_OAUTH_TOKEN"
|
||||
|
||||
def test_source_env_var_anthropic_auth(self):
|
||||
"""Identifies ANTHROPIC_AUTH_TOKEN as source."""
|
||||
os.environ["ANTHROPIC_AUTH_TOKEN"] = "sk-ant-oat01-test-token"
|
||||
|
||||
source = get_auth_token_source()
|
||||
assert source == "ANTHROPIC_AUTH_TOKEN"
|
||||
|
||||
def test_source_macos_keychain(self, monkeypatch):
|
||||
"""Identifies macOS Keychain as source."""
|
||||
test_token = "sk-ant-oat01-macos-token"
|
||||
credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}})
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = credentials
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result))
|
||||
|
||||
source = get_auth_token_source()
|
||||
assert source == "macOS Keychain"
|
||||
|
||||
def test_source_windows_credential_files(self, monkeypatch, tmp_path):
|
||||
"""Identifies Windows Credential Files as source."""
|
||||
test_token = "sk-ant-oat01-windows-token"
|
||||
credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}})
|
||||
|
||||
cred_file = tmp_path / ".credentials.json"
|
||||
cred_file.write_text(credentials)
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
||||
monkeypatch.setattr(
|
||||
os.path, "expandvars", lambda p: str(cred_file).replace("\\", "/")
|
||||
)
|
||||
|
||||
source = get_auth_token_source()
|
||||
assert source == "Windows Credential Files"
|
||||
|
||||
def test_source_linux_secret_service(self, monkeypatch):
|
||||
"""Identifies Linux Secret Service as source."""
|
||||
test_token = "sk-ant-oat01-linux-token"
|
||||
credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}})
|
||||
|
||||
mock_ss = MagicMock()
|
||||
mock_ss.exceptions = MagicMock()
|
||||
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
||||
mock_ss.exceptions.SecretStorageException = Exception
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.is_locked.return_value = False
|
||||
|
||||
mock_item = MagicMock()
|
||||
mock_item.get_label.return_value = "Claude Code-credentials"
|
||||
mock_item.get_secret.return_value = credentials
|
||||
|
||||
mock_collection.search_items.return_value = [mock_item]
|
||||
mock_ss.get_default_collection.return_value = mock_collection
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
||||
|
||||
source = get_auth_token_source()
|
||||
assert source == "Linux Secret Service"
|
||||
|
||||
def test_source_none_when_not_found(self):
|
||||
"""Returns None when no token source is found."""
|
||||
source = get_auth_token_source()
|
||||
assert source is None
|
||||
|
||||
|
||||
class TestSdkEnvVars:
|
||||
"""Tests for get_sdk_env_vars function."""
|
||||
|
||||
def test_returns_non_empty_vars(self, monkeypatch):
|
||||
"""Only returns non-empty environment variables."""
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
|
||||
monkeypatch.setenv("ANTHROPIC_MODEL", "") # Empty, should be excluded
|
||||
monkeypatch.setenv("DISABLE_TELEMETRY", "1")
|
||||
|
||||
env = get_sdk_env_vars()
|
||||
|
||||
assert "ANTHROPIC_BASE_URL" in env
|
||||
assert env["ANTHROPIC_BASE_URL"] == "https://api.anthropic.com"
|
||||
assert "ANTHROPIC_MODEL" not in env # Empty value excluded
|
||||
assert "DISABLE_TELEMETRY" in env
|
||||
assert env["DISABLE_TELEMETRY"] == "1"
|
||||
|
||||
def test_includes_claude_git_bash_on_windows(self, monkeypatch):
|
||||
"""Auto-detects git-bash path on Windows."""
|
||||
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
||||
monkeypatch.setattr(
|
||||
"core.auth._find_git_bash_path",
|
||||
lambda: "C:\\Program Files\\Git\\bin\\bash.exe",
|
||||
)
|
||||
|
||||
env = get_sdk_env_vars()
|
||||
|
||||
assert "CLAUDE_CODE_GIT_BASH_PATH" in env
|
||||
assert "Git\\bin\\bash.exe" in env["CLAUDE_CODE_GIT_BASH_PATH"]
|
||||
|
||||
def test_does_not_include_git_bash_on_non_windows(self, monkeypatch):
|
||||
"""Doesn't include git-bash path on non-Windows platforms."""
|
||||
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
||||
|
||||
env = get_sdk_env_vars()
|
||||
|
||||
assert "CLAUDE_CODE_GIT_BASH_PATH" not in env
|
||||
|
||||
def test_does_not_overwrite_existing_git_bash_path(self, monkeypatch):
|
||||
"""Respects existing CLAUDE_CODE_GIT_BASH_PATH environment variable."""
|
||||
existing_path = "/custom/bash.exe"
|
||||
monkeypatch.setenv("CLAUDE_CODE_GIT_BASH_PATH", existing_path)
|
||||
|
||||
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
||||
|
||||
env = get_sdk_env_vars()
|
||||
|
||||
assert env["CLAUDE_CODE_GIT_BASH_PATH"] == existing_path
|
||||
|
||||
Reference in New Issue
Block a user