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:
StillKnotKnown
2026-01-16 10:33:48 +02:00
committed by GitHub
parent 10bceac9cb
commit a6934a8edf
3 changed files with 672 additions and 99 deletions
+95 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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