11f8d572ff
* feat(auth): replace setup-token with embedded /login terminal flow Migrate OAuth authentication from external `claude setup-token` command to an embedded terminal experience using `claude /login`: - Add AuthTerminal component for in-app authentication - Add session migration between profiles on profile switch - Add keychain utilities for macOS credential detection - Add profile change hook for terminal refresh after switch - Update error messages to reference /login instead of setup-token - Add i18n translations for all auth UI strings (EN + FR) - Fix Windows path handling in session utils - Harden Python temp file security (0o700 permissions, try-finally cleanup) Cross-platform support: - macOS: Full keychain integration - Windows: Credential files + .claude.json verification - Linux: Secret Service + .claude.json verification Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(auth): enhance OAuth flow with onboarding support - Update OAuth token handling to prioritize configDir over stored tokens, allowing full Keychain credential access including subscription type and rate limit tier. - Introduce `needsOnboarding` flag in OAuthTokenEvent to indicate when users must complete setup in the terminal. - Modify AuthTerminal component to reflect onboarding status and provide user guidance. - Update i18n strings for improved clarity on authentication steps and onboarding messages. - Fix tests This change improves the user experience by ensuring users are aware of necessary onboarding steps after receiving their OAuth token. * feat(auth): add onboarding complete detection and auto-close Detect when Claude Code shows the welcome/ready screen after OAuth login and automatically close the auth terminal. This improves the login UX by: - Adding handleOnboardingComplete() to detect ready state patterns - Auto-closing auth terminal after successful onboarding - Supporting re-authentication flow (logout first, then login) - Extracting email from welcome screen to update profiles - Adding TERMINAL_ONBOARDING_COMPLETE IPC channel * fix(auth): add backwards compatibility for re-authenticating old setup-token profiles When re-authenticating a profile that was set up with the old setup-token system, the browser wasn't opening because Claude CLI detected existing credentials in .claude.json and skipped the OAuth flow. Now when authenticateClaudeProfile is called: - Check if .claude.json exists with oauthAccount credentials - Back up existing credentials to .claude.json.bak - Allow /login to start fresh and open the browser for OAuth This ensures smooth transition from the old setup-token system to the new /login flow for existing profiles. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: remove unused isReauth prop from auth terminal components Clean up the isReauth approach that was replaced by the backend fix (backing up .claude.json before re-authentication). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(tests): remove obsolete CLAUDE_PROFILE_INITIALIZE tests and fix extractEmail expectation - Remove CLAUDE_PROFILE_INITIALIZE handler tests since the handler was deprecated as part of the migration to the new /login OAuth flow - Fix extractEmail test to expect correct behavior (email extraction now works for "Authenticated as user@example.com" format) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(auth): use platform detection functions instead of undefined platform module Replace platform.system() calls with is_macos() and is_windows() functions that are already imported from core.platform. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR review findings (8 issues) HIGH priority fixes: - session-utils: Strip Windows drive letters to avoid invalid colons in paths - AuthTerminal: Use Math.max(0, ...) to prevent RangeError on long translations MEDIUM priority fixes: - session-utils: Clean up orphaned session file on partial migration failure - AuthTerminal: Add authCompletedRef to prevent race condition double-callback - AuthTerminal: Add successTimeoutRef for proper cleanup on unmount - claude-code-handlers: Add escapeBashCommand() for Linux terminal defense-in-depth LOW priority fixes: - keychain-utils: Use isMacOS() from platform module instead of direct check - claude-integration-handler: Centralize AUTH_TERMINAL_ID_PATTERN constant Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: complete remaining PR review issues (#7, #9) Issue #7 (MEDIUM): Code duplication in invokeClaude/invokeClaudeAsync - Add comprehensive unit tests for both functions (265 lines) - Extract shared logic into executeProfileCommand() and executeProfileCommandAsync() - Reduce ~60 lines of duplication while maintaining clarity - All 75 tests passing Issue #9 (LOW): Keychain error handling indistinguishable - Add optional error field to KeychainCredentials interface - Distinguish "not found" (exit 44) from actual failures - Update call site to log appropriate error instead of "will retry" - Backward compatible change Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ci: enable CodeQL scanning on all PRs Remove the if condition that was skipping CodeQL on pull requests. CodeQL will now run on all PRs, pushes to main, and weekly schedule. Note: This adds 40-60 min to PR checks but provides full security scanning. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address follow-up PR review findings (6 issues) MEDIUM priority fixes: - NEW-006: Add backup restoration when auth fails (.claude.json.bak) - NEW-002: Add configDir path validation to prevent arbitrary file reads - New utility: config-path-validator.ts - Validates paths in checkProfileAuthentication, CLAUDE_PROFILE_AUTHENTICATE, and CLAUDE_PROFILE_SAVE handlers LOW priority fixes: - NEW-003: Remove token length from warning logs (security hardening) - NEW-004: Eliminate TOCTOU race conditions in session migration - NEW-007: Remove sensitive buffer contents from debug logs - NEW-008: Use private temp directory for expect script (auth.py) FALSE POSITIVE (no fix needed): - NEW-005: Keychain cache keys are already unique per profile (hash-based) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: path validator test mock and boundary check - Mock isValidConfigDir in tests to allow temp directory paths - Add path separator boundary check to prevent path traversal (e.g., /home/alice-malicious bypassing /home/alice validation) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address all PR review findings with quality improvements - LOGIC-001: Error results now cache for 10s (vs 5min) for quick recovery - SEC-001: Email logging changed to boolean hasEmail flag for privacy - LOGIC-004: Fixed misleading 'will retry' log message - TEST-001: Added 35 comprehensive unit tests for path validator - LOGIC-002: Replaced string matching with error.code checks - QUAL-002: Moved dynamic require to top-level import - QUAL-003: Refactored nested try-finally to TemporaryDirectory Additional quality improvements: - Extract isNodeError type guard to shared utils/type-guards.ts - Fix logging convention (console.log for success, warn for errors) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address cursor bot and additional review findings Fixes: - Linux command escaping: Remove escapeBashCommand for trusted install commands that use semicolons as statement separators - Session path format: Keep leading dash to match Claude CLI format (-Users-foo-bar instead of Users-foo-bar) - Platform test coverage: Run Unix path tests on all platforms, document Node.js path.resolve() platform-specific behavior - Security executable: Use path resolution instead of hardcoded /usr/bin/security - PII logging: Add maskEmail() helper and redact emails in console.warn calls Additional quality improvements (NEW-003 through NEW-006): - Token validation: Use 'sk-ant-' prefix for future compatibility - Logging level: Use console.debug for successful credential retrieval - Stale backup cleanup: Remove old .claude.json.bak on auth start - Temp directory: Remove predictable prefix for defense-in-depth Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: remove premature backup deletion that could lose valid credentials NEW-005-REVIEW: The stale backup cleanup at AUTHENTICATE handler was flawed. It assumed "both .claude.json and .bak exist = previous auth succeeded" but this is wrong - the app could have crashed after /login wrote an incomplete .claude.json but before VERIFY_AUTH confirmed valid credentials. Removed the premature cleanup. Backup deletion now only happens: 1. In VERIFY_AUTH after confirming valid credentials (safe) 2. When creating a new backup (removes old backup first) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: AUTH_TERMINAL_ID_PATTERN regex to match actual profile IDs The old regex only matched 'default' or 'profile-\d+' but actual profile IDs are sanitized names like 'work', 'my-profile' generated by generateProfileId(). This caused OAuth token capture to silently fail for all non-default profiles. Updated regex to match the actual profile ID format: lowercase letters, numbers, and hyphens with a 13+ digit timestamp suffix. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Test User <test@example.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
899 lines
30 KiB
Python
899 lines
30 KiB
Python
"""
|
|
Authentication helpers for Auto Claude.
|
|
|
|
Provides centralized authentication token resolution with fallback support
|
|
for multiple environment variables, and SDK environment variable passthrough
|
|
for custom API endpoints.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
from typing import TYPE_CHECKING
|
|
|
|
from core.platform import (
|
|
find_executable,
|
|
get_claude_detection_paths,
|
|
is_linux,
|
|
is_macos,
|
|
is_windows,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 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.
|
|
# Auto Claude is designed to use Claude Code OAuth tokens only.
|
|
# This prevents silent billing to user's API credits when OAuth fails.
|
|
AUTH_TOKEN_ENV_VARS = [
|
|
"CLAUDE_CODE_OAUTH_TOKEN", # OAuth token from Claude Code CLI
|
|
"ANTHROPIC_AUTH_TOKEN", # CCR/proxy token (for enterprise setups)
|
|
]
|
|
|
|
# Environment variables to pass through to SDK subprocess
|
|
# NOTE: ANTHROPIC_API_KEY is intentionally excluded to prevent silent API billing
|
|
SDK_ENV_VARS = [
|
|
# API endpoint configuration
|
|
"ANTHROPIC_BASE_URL",
|
|
"ANTHROPIC_AUTH_TOKEN",
|
|
# Model overrides (from API Profile custom model mappings)
|
|
"ANTHROPIC_MODEL",
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
# SDK behavior configuration
|
|
"NO_PROXY",
|
|
"DISABLE_TELEMETRY",
|
|
"DISABLE_COST_WARNINGS",
|
|
"API_TIMEOUT_MS",
|
|
# Windows-specific: Git Bash path for Claude Code CLI
|
|
"CLAUDE_CODE_GIT_BASH_PATH",
|
|
]
|
|
|
|
|
|
def is_encrypted_token(token: str | None) -> bool:
|
|
"""
|
|
Check if a token is encrypted (has "enc:" prefix).
|
|
|
|
Args:
|
|
token: Token string to check (can be None)
|
|
|
|
Returns:
|
|
True if token starts with "enc:", False otherwise
|
|
"""
|
|
return bool(token and token.startswith("enc:"))
|
|
|
|
|
|
def validate_token_not_encrypted(token: str) -> None:
|
|
"""
|
|
Validate that a token is not in encrypted format.
|
|
|
|
This function should be called before passing a token to the Claude Agent SDK
|
|
to ensure proper error messages when decryption has failed.
|
|
|
|
Args:
|
|
token: Token string to validate
|
|
|
|
Raises:
|
|
ValueError: If token is in encrypted format (enc:...)
|
|
"""
|
|
if is_encrypted_token(token):
|
|
raise ValueError(
|
|
"Authentication token is in encrypted format and cannot be used.\n\n"
|
|
"The token decryption process failed or was not attempted.\n\n"
|
|
"To fix this issue:\n"
|
|
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
|
|
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file\n\n"
|
|
"Note: Encrypted tokens require the Claude Code CLI to be installed\n"
|
|
"and properly configured with system keychain access."
|
|
)
|
|
|
|
|
|
def decrypt_token(encrypted_token: str) -> str:
|
|
"""
|
|
Decrypt Claude Code encrypted token.
|
|
|
|
NOTE: This implementation currently relies on the system keychain (macOS Keychain,
|
|
Linux Secret Service, Windows Credential Manager) to provide already-decrypted tokens.
|
|
Encrypted tokens in the CLAUDE_CODE_OAUTH_TOKEN environment variable are NOT supported
|
|
and will fail with NotImplementedError.
|
|
|
|
For encrypted token support, users should:
|
|
1. Run: claude setup-token (stores decrypted token in system keychain)
|
|
2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file
|
|
|
|
Claude Code CLI stores OAuth tokens in encrypted format with "enc:" prefix.
|
|
This function attempts to decrypt the token using platform-specific methods.
|
|
|
|
Cross-platform token decryption approaches:
|
|
- macOS: Token stored in Keychain with encryption key
|
|
- Linux: Token stored in Secret Service API with encryption key
|
|
- Windows: Token stored in Credential Manager or .credentials.json
|
|
|
|
Args:
|
|
encrypted_token: Token with 'enc:' prefix from Claude Code CLI
|
|
|
|
Returns:
|
|
Decrypted token in format 'sk-ant-oat01-...'
|
|
|
|
Raises:
|
|
ValueError: If token format is invalid or decryption fails
|
|
"""
|
|
# Validate encrypted token format
|
|
if not isinstance(encrypted_token, str):
|
|
raise ValueError(
|
|
f"Invalid token type. Expected string, got: {type(encrypted_token).__name__}"
|
|
)
|
|
|
|
if not encrypted_token.startswith("enc:"):
|
|
raise ValueError(
|
|
"Invalid encrypted token format. Token must start with 'enc:' prefix."
|
|
)
|
|
|
|
# Remove 'enc:' prefix to get encrypted data
|
|
encrypted_data = encrypted_token[4:]
|
|
|
|
if not encrypted_data:
|
|
raise ValueError("Empty encrypted token data after 'enc:' prefix")
|
|
|
|
# Basic validation of encrypted data format
|
|
# Encrypted data should be a reasonable length (at least 10 chars)
|
|
if len(encrypted_data) < 10:
|
|
raise ValueError(
|
|
"Encrypted token data is too short. The token may be corrupted."
|
|
)
|
|
|
|
# Check for obviously invalid characters that suggest corruption
|
|
# Accepts both standard base64 (+/) and URL-safe base64 (-_) to be permissive
|
|
if not all(c.isalnum() or c in "+-_/=" for c in encrypted_data):
|
|
raise ValueError(
|
|
"Encrypted token contains invalid characters. "
|
|
"Expected base64-encoded data. The token may be corrupted."
|
|
)
|
|
|
|
# Attempt platform-specific decryption
|
|
try:
|
|
if is_macos():
|
|
return _decrypt_token_macos(encrypted_data)
|
|
elif is_linux():
|
|
return _decrypt_token_linux(encrypted_data)
|
|
elif is_windows():
|
|
return _decrypt_token_windows(encrypted_data)
|
|
else:
|
|
raise ValueError("Unsupported platform for token decryption")
|
|
|
|
except NotImplementedError as e:
|
|
# Decryption not implemented - log warning and provide guidance
|
|
logger.warning(
|
|
"Token decryption failed: %s. Users must use plaintext tokens.", str(e)
|
|
)
|
|
raise ValueError(
|
|
f"Encrypted token decryption is not yet implemented: {str(e)}\n\n"
|
|
"To fix this issue:\n"
|
|
" 1. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token (without 'enc:' prefix)\n"
|
|
" 2. Or re-authenticate with: claude setup-token"
|
|
)
|
|
except ValueError:
|
|
# Re-raise ValueError as-is (already has good error message)
|
|
raise
|
|
except FileNotFoundError as e:
|
|
# File-related errors (missing credentials file, missing binary)
|
|
raise ValueError(
|
|
f"Failed to decrypt token - required file not found: {str(e)}\n\n"
|
|
"To fix this issue:\n"
|
|
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
|
|
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file"
|
|
)
|
|
except PermissionError as e:
|
|
# Permission errors (can't access keychain, credential manager, etc.)
|
|
raise ValueError(
|
|
f"Failed to decrypt token - permission denied: {str(e)}\n\n"
|
|
"To fix this issue:\n"
|
|
" 1. Grant keychain/credential manager access to this application\n"
|
|
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file"
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
# Timeout during decryption process
|
|
raise ValueError(
|
|
"Failed to decrypt token - operation timed out.\n\n"
|
|
"This may indicate a problem with system keychain access.\n\n"
|
|
"To fix this issue:\n"
|
|
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
|
|
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file"
|
|
)
|
|
except Exception as e:
|
|
# Catch-all for other errors - provide helpful error message
|
|
error_type = type(e).__name__
|
|
raise ValueError(
|
|
f"Failed to decrypt token ({error_type}): {str(e)}\n\n"
|
|
"To fix this issue:\n"
|
|
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
|
|
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file\n\n"
|
|
"Note: Encrypted tokens (enc:...) require the Claude Code CLI to be installed\n"
|
|
"and properly configured with system keychain access."
|
|
)
|
|
|
|
|
|
def _decrypt_token_macos(encrypted_data: str) -> str:
|
|
"""
|
|
Decrypt token on macOS using Keychain.
|
|
|
|
Args:
|
|
encrypted_data: Encrypted token data (without 'enc:' prefix)
|
|
|
|
Returns:
|
|
Decrypted token
|
|
|
|
Raises:
|
|
ValueError: If decryption fails or Claude CLI not available
|
|
"""
|
|
# Verify Claude CLI is installed (required for future decryption implementation)
|
|
if not find_executable("claude", get_claude_detection_paths()):
|
|
raise ValueError(
|
|
"Claude Code CLI not found. Please install it from https://code.claude.com"
|
|
)
|
|
|
|
# The Claude Code CLI handles token decryption internally when it runs
|
|
# We can trigger this by running a simple command that requires authentication
|
|
# and capturing the decrypted token from the environment it sets up
|
|
#
|
|
# However, there's no direct CLI command to decrypt tokens.
|
|
# The SDK should handle this automatically when it receives encrypted tokens.
|
|
raise NotImplementedError(
|
|
"Encrypted tokens in environment variables are not supported. "
|
|
"Please use one of these options:\n"
|
|
" 1. Run 'claude setup-token' to store token in system keychain\n"
|
|
" 2. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file\n\n"
|
|
"Note: This requires Claude Agent SDK >= 0.1.19"
|
|
)
|
|
|
|
|
|
def _decrypt_token_linux(encrypted_data: str) -> str:
|
|
"""
|
|
Decrypt token on Linux using Secret Service API.
|
|
|
|
Args:
|
|
encrypted_data: Encrypted token data (without 'enc:' prefix)
|
|
|
|
Returns:
|
|
Decrypted token
|
|
|
|
Raises:
|
|
ValueError: If decryption fails or dependencies not available
|
|
"""
|
|
# Linux token decryption requires secretstorage library
|
|
if secretstorage is None:
|
|
raise ValueError(
|
|
"secretstorage library not found. Install it with: pip install secretstorage"
|
|
)
|
|
|
|
# Similar to macOS, the actual decryption mechanism isn't publicly documented
|
|
# The Claude Agent SDK should handle this automatically
|
|
raise NotImplementedError(
|
|
"Encrypted tokens in environment variables are not supported. "
|
|
"Please use one of these options:\n"
|
|
" 1. Run 'claude setup-token' to store token in system keychain\n"
|
|
" 2. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file\n\n"
|
|
"Note: This requires Claude Agent SDK >= 0.1.19"
|
|
)
|
|
|
|
|
|
def _decrypt_token_windows(encrypted_data: str) -> str:
|
|
"""
|
|
Decrypt token on Windows using Credential Manager.
|
|
|
|
Args:
|
|
encrypted_data: Encrypted token data (without 'enc:' prefix)
|
|
|
|
Returns:
|
|
Decrypted token
|
|
|
|
Raises:
|
|
ValueError: If decryption fails
|
|
"""
|
|
# Windows token decryption from Credential Manager or .credentials.json
|
|
# The Claude Agent SDK should handle this automatically
|
|
raise NotImplementedError(
|
|
"Encrypted tokens in environment variables are not supported. "
|
|
"Please use one of these options:\n"
|
|
" 1. Run 'claude setup-token' to store token in system keychain\n"
|
|
" 2. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file\n\n"
|
|
"Note: This requires Claude Agent SDK >= 0.1.19"
|
|
)
|
|
|
|
|
|
def get_token_from_keychain() -> str | None:
|
|
"""
|
|
Get authentication token from system credential store.
|
|
|
|
Reads Claude Code credentials from:
|
|
- macOS: Keychain
|
|
- Windows: Credential Manager
|
|
- Linux: Secret Service API (via dbus/secretstorage)
|
|
|
|
Returns:
|
|
Token string if found, None otherwise
|
|
"""
|
|
if is_macos():
|
|
return _get_token_from_macos_keychain()
|
|
elif is_windows():
|
|
return _get_token_from_windows_credential_files()
|
|
else:
|
|
# Linux: use secret-service API via DBus
|
|
return _get_token_from_linux_secret_service()
|
|
|
|
|
|
def _get_token_from_macos_keychain() -> str | None:
|
|
"""Get token from macOS Keychain."""
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"/usr/bin/security",
|
|
"find-generic-password",
|
|
"-s",
|
|
"Claude Code-credentials",
|
|
"-w",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
return None
|
|
|
|
credentials_json = result.stdout.strip()
|
|
if not credentials_json:
|
|
return None
|
|
|
|
data = json.loads(credentials_json)
|
|
token = data.get("claudeAiOauth", {}).get("accessToken")
|
|
|
|
if not token:
|
|
return None
|
|
|
|
# Validate token format (Claude OAuth tokens start with sk-ant-oat01-)
|
|
if not token.startswith("sk-ant-oat01-"):
|
|
return None
|
|
|
|
return token
|
|
|
|
except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, Exception):
|
|
return None
|
|
|
|
|
|
def _get_token_from_windows_credential_files() -> str | None:
|
|
"""Get token from Windows credential files.
|
|
|
|
Claude Code on Windows stores credentials in ~/.claude/.credentials.json
|
|
"""
|
|
try:
|
|
# Claude Code stores credentials in ~/.claude/.credentials.json
|
|
cred_paths = [
|
|
os.path.expandvars(r"%USERPROFILE%\.claude\.credentials.json"),
|
|
os.path.expandvars(r"%USERPROFILE%\.claude\credentials.json"),
|
|
os.path.expandvars(r"%LOCALAPPDATA%\Claude\credentials.json"),
|
|
os.path.expandvars(r"%APPDATA%\Claude\credentials.json"),
|
|
]
|
|
|
|
for cred_path in cred_paths:
|
|
if os.path.exists(cred_path):
|
|
with open(cred_path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
token = data.get("claudeAiOauth", {}).get("accessToken")
|
|
if token and token.startswith("sk-ant-oat01-"):
|
|
return token
|
|
|
|
return None
|
|
|
|
except (json.JSONDecodeError, KeyError, FileNotFoundError, Exception):
|
|
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.
|
|
|
|
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, Linux Secret Service)
|
|
|
|
NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent
|
|
silent billing to user's API credits when OAuth is misconfigured.
|
|
|
|
If the token has an "enc:" prefix (encrypted format), it will be automatically
|
|
decrypted before being returned.
|
|
|
|
Returns:
|
|
Token string if found, None otherwise
|
|
"""
|
|
# First check environment variables
|
|
for var in AUTH_TOKEN_ENV_VARS:
|
|
token = os.environ.get(var)
|
|
if token:
|
|
# Decrypt if token is encrypted
|
|
if is_encrypted_token(token):
|
|
try:
|
|
token = decrypt_token(token)
|
|
except ValueError:
|
|
# Decryption failed - return encrypted token so client validation
|
|
# can provide specific error message about encrypted format
|
|
return token
|
|
return token
|
|
|
|
# Fallback to system credential store
|
|
token = get_token_from_keychain()
|
|
if token and is_encrypted_token(token):
|
|
try:
|
|
token = decrypt_token(token)
|
|
except ValueError:
|
|
# Decryption failed - return encrypted token so client validation
|
|
# (validate_token_not_encrypted) can provide specific error message.
|
|
# This is consistent with env var handling above.
|
|
return token
|
|
return token
|
|
|
|
|
|
def get_auth_token_source() -> str | None:
|
|
"""Get the name of the source that provided the auth token."""
|
|
# Check environment variables first
|
|
for var in AUTH_TOKEN_ENV_VARS:
|
|
if os.environ.get(var):
|
|
return var
|
|
|
|
# Check if token came from system credential store
|
|
if get_token_from_keychain():
|
|
if is_macos():
|
|
return "macOS Keychain"
|
|
elif is_windows():
|
|
return "Windows Credential Files"
|
|
else:
|
|
return "Linux Secret Service"
|
|
|
|
return None
|
|
|
|
|
|
def require_auth_token() -> str:
|
|
"""
|
|
Get authentication token or raise ValueError.
|
|
|
|
Raises:
|
|
ValueError: If no auth token is found in any supported source
|
|
"""
|
|
token = get_auth_token()
|
|
if not token:
|
|
error_msg = (
|
|
"No OAuth token found.\n\n"
|
|
"Auto Claude requires Claude Code OAuth authentication.\n"
|
|
"Direct API keys (ANTHROPIC_API_KEY) are not supported.\n\n"
|
|
)
|
|
# Provide platform-specific guidance
|
|
if is_macos():
|
|
error_msg += (
|
|
"To authenticate:\n"
|
|
" 1. Run: claude\n"
|
|
" 2. Type: /login\n"
|
|
" 3. Press Enter to open browser\n"
|
|
" 4. Complete OAuth login in browser\n\n"
|
|
"The token will be saved to macOS Keychain automatically."
|
|
)
|
|
elif is_windows():
|
|
error_msg += (
|
|
"To authenticate:\n"
|
|
" 1. Run: claude\n"
|
|
" 2. Type: /login\n"
|
|
" 3. Press Enter to open browser\n"
|
|
" 4. Complete OAuth login in browser\n\n"
|
|
"The token will be saved to Windows Credential Manager."
|
|
)
|
|
else:
|
|
# Linux
|
|
error_msg += (
|
|
"To authenticate:\n"
|
|
" 1. Run: claude\n"
|
|
" 2. Type: /login\n"
|
|
" 3. Press Enter to open browser\n"
|
|
" 4. Complete OAuth login in browser\n\n"
|
|
"Or set CLAUDE_CODE_OAUTH_TOKEN in your .env file."
|
|
)
|
|
raise ValueError(error_msg)
|
|
return token
|
|
|
|
|
|
def _find_git_bash_path() -> str | None:
|
|
"""
|
|
Find git-bash (bash.exe) path on Windows.
|
|
|
|
Uses 'where git' to find git.exe, then derives bash.exe location from it.
|
|
Git for Windows installs bash.exe in the 'bin' directory alongside git.exe
|
|
or in the parent 'bin' directory when git.exe is in 'cmd'.
|
|
|
|
Returns:
|
|
Full path to bash.exe if found, None otherwise
|
|
"""
|
|
if not is_windows():
|
|
return None
|
|
|
|
# If already set in environment, use that
|
|
existing = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH")
|
|
if existing and os.path.exists(existing):
|
|
return existing
|
|
|
|
git_path = None
|
|
|
|
# Method 1: Use 'where' command to find git.exe
|
|
try:
|
|
# Use where.exe explicitly for reliability
|
|
result = subprocess.run(
|
|
["where.exe", "git"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
shell=False,
|
|
)
|
|
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
git_paths = result.stdout.strip().splitlines()
|
|
if git_paths:
|
|
git_path = git_paths[0].strip()
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
|
# Intentionally suppress errors - best-effort detection with fallback to common paths
|
|
pass
|
|
|
|
# Method 2: Check common installation paths if 'where' didn't work
|
|
if not git_path:
|
|
common_git_paths = [
|
|
os.path.expandvars(r"%PROGRAMFILES%\Git\cmd\git.exe"),
|
|
os.path.expandvars(r"%PROGRAMFILES%\Git\bin\git.exe"),
|
|
os.path.expandvars(r"%PROGRAMFILES(X86)%\Git\cmd\git.exe"),
|
|
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\cmd\git.exe"),
|
|
]
|
|
for path in common_git_paths:
|
|
if os.path.exists(path):
|
|
git_path = path
|
|
break
|
|
|
|
if not git_path:
|
|
return None
|
|
|
|
# Derive bash.exe location from git.exe location
|
|
# Git for Windows structure:
|
|
# C:\...\Git\cmd\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
|
# C:\...\Git\bin\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
|
# C:\...\Git\mingw64\bin\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
|
git_dir = os.path.dirname(git_path)
|
|
git_parent = os.path.dirname(git_dir)
|
|
git_grandparent = os.path.dirname(git_parent)
|
|
|
|
# Check common bash.exe locations relative to git installation
|
|
possible_bash_paths = [
|
|
os.path.join(git_parent, "bin", "bash.exe"), # cmd -> bin
|
|
os.path.join(git_dir, "bash.exe"), # If git.exe is in bin
|
|
os.path.join(git_grandparent, "bin", "bash.exe"), # mingw64/bin -> bin
|
|
]
|
|
|
|
for bash_path in possible_bash_paths:
|
|
if os.path.exists(bash_path):
|
|
return bash_path
|
|
|
|
return None
|
|
|
|
|
|
def get_sdk_env_vars() -> dict[str, str]:
|
|
"""
|
|
Get environment variables to pass to SDK.
|
|
|
|
Collects relevant env vars (ANTHROPIC_BASE_URL, etc.) that should
|
|
be passed through to the claude-agent-sdk subprocess.
|
|
|
|
On Windows, auto-detects CLAUDE_CODE_GIT_BASH_PATH if not already set.
|
|
|
|
Returns:
|
|
Dict of env var name -> value for non-empty vars
|
|
"""
|
|
env = {}
|
|
for var in SDK_ENV_VARS:
|
|
value = os.environ.get(var)
|
|
if value:
|
|
env[var] = value
|
|
|
|
# On Windows, auto-detect git-bash path if not already set
|
|
# Claude Code CLI requires bash.exe to run on Windows
|
|
if is_windows() and "CLAUDE_CODE_GIT_BASH_PATH" not in env:
|
|
bash_path = _find_git_bash_path()
|
|
if bash_path:
|
|
env["CLAUDE_CODE_GIT_BASH_PATH"] = bash_path
|
|
|
|
# Explicitly unset PYTHONPATH in SDK subprocess environment to prevent
|
|
# pollution of agent subprocess environments. This fixes ACS-251 where
|
|
# external projects with different Python versions would fail due to
|
|
# inheriting Auto-Claude's PYTHONPATH (which points to Python 3.12 packages).
|
|
#
|
|
# The SDK merges os.environ with the env dict we provide, so setting
|
|
# PYTHONPATH to an empty string here overrides any inherited value.
|
|
# The empty string ensures Python doesn't add any extra paths to sys.path.
|
|
env["PYTHONPATH"] = ""
|
|
|
|
return env
|
|
|
|
|
|
def ensure_claude_code_oauth_token() -> None:
|
|
"""
|
|
Ensure CLAUDE_CODE_OAUTH_TOKEN is set (for SDK compatibility).
|
|
|
|
If not set but other auth tokens are available, copies the value
|
|
to CLAUDE_CODE_OAUTH_TOKEN so the underlying SDK can use it.
|
|
"""
|
|
if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
|
|
return
|
|
|
|
token = get_auth_token()
|
|
if token:
|
|
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token
|
|
|
|
|
|
def trigger_login() -> bool:
|
|
"""
|
|
Trigger Claude Code OAuth login flow.
|
|
|
|
Opens the Claude Code CLI and sends /login command to initiate
|
|
browser-based OAuth authentication. The token is automatically
|
|
saved to the system credential store (macOS Keychain, Windows
|
|
Credential Manager).
|
|
|
|
Returns:
|
|
True if login was successful, False otherwise
|
|
"""
|
|
if is_macos():
|
|
return _trigger_login_macos()
|
|
elif is_windows():
|
|
return _trigger_login_windows()
|
|
else:
|
|
# Linux: fall back to manual instructions
|
|
print("\nTo authenticate, run 'claude' and type '/login'")
|
|
return False
|
|
|
|
|
|
def _trigger_login_macos() -> bool:
|
|
"""Trigger login on macOS using expect."""
|
|
import shutil
|
|
import tempfile
|
|
|
|
# Check if expect is available
|
|
if not shutil.which("expect"):
|
|
print("\nTo authenticate, run 'claude' and type '/login'")
|
|
return False
|
|
|
|
# Create expect script
|
|
expect_script = """#!/usr/bin/expect -f
|
|
set timeout 120
|
|
spawn claude
|
|
expect {
|
|
-re ".*" {
|
|
send "/login\\r"
|
|
expect {
|
|
"Press Enter" {
|
|
send "\\r"
|
|
}
|
|
-re ".*login.*" {
|
|
send "\\r"
|
|
}
|
|
timeout {
|
|
send "\\r"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
# Keep running until user completes login or exits
|
|
interact
|
|
"""
|
|
|
|
# Use TemporaryDirectory context manager for automatic cleanup
|
|
# This prevents information leakage about authentication activity
|
|
# Directory created with mode 0o700 (owner read/write/execute only)
|
|
try:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
# Ensure directory has owner-only permissions
|
|
os.chmod(temp_dir, 0o700)
|
|
|
|
# Write expect script to temp file in our private directory
|
|
script_path = os.path.join(temp_dir, "login.exp")
|
|
with open(script_path, "w", encoding="utf-8") as f:
|
|
f.write(expect_script)
|
|
|
|
# Set script permissions to owner-only (0o700)
|
|
os.chmod(script_path, 0o700)
|
|
|
|
print("\n" + "=" * 60)
|
|
print("CLAUDE CODE LOGIN")
|
|
print("=" * 60)
|
|
print("\nOpening Claude Code for authentication...")
|
|
print("A browser window will open for OAuth login.")
|
|
print("After completing login in the browser, press Ctrl+C to exit.\n")
|
|
|
|
# Run expect script
|
|
result = subprocess.run(
|
|
["expect", script_path],
|
|
timeout=300, # 5 minute timeout
|
|
)
|
|
|
|
# Verify token was saved
|
|
token = get_token_from_keychain()
|
|
if token:
|
|
print("\n✓ Login successful! Token saved to macOS Keychain.")
|
|
return True
|
|
else:
|
|
print(
|
|
"\n✗ Login may not have completed. Try running 'claude' and type '/login'"
|
|
)
|
|
return False
|
|
|
|
except subprocess.TimeoutExpired:
|
|
print("\nLogin timed out. Try running 'claude' manually and type '/login'")
|
|
return False
|
|
except KeyboardInterrupt:
|
|
# User pressed Ctrl+C - check if login completed
|
|
token = get_token_from_keychain()
|
|
if token:
|
|
print("\n✓ Login successful! Token saved to macOS Keychain.")
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
print(f"\nLogin failed: {e}")
|
|
print("Try running 'claude' manually and type '/login'")
|
|
return False
|
|
|
|
|
|
def _trigger_login_windows() -> bool:
|
|
"""Trigger login on Windows."""
|
|
# Windows doesn't have expect by default, so we use a simpler approach
|
|
# that just launches claude and tells the user what to type
|
|
print("\n" + "=" * 60)
|
|
print("CLAUDE CODE LOGIN")
|
|
print("=" * 60)
|
|
print("\nLaunching Claude Code...")
|
|
print("Please type '/login' and press Enter.")
|
|
print("A browser window will open for OAuth login.\n")
|
|
|
|
try:
|
|
# Launch claude interactively
|
|
subprocess.run(["claude"], timeout=300)
|
|
|
|
# Verify token was saved
|
|
token = _get_token_from_windows_credential_files()
|
|
if token:
|
|
print("\n✓ Login successful!")
|
|
return True
|
|
else:
|
|
print("\n✗ Login may not have completed.")
|
|
return False
|
|
|
|
except Exception as e:
|
|
print(f"\nLogin failed: {e}")
|
|
return False
|
|
|
|
|
|
def ensure_authenticated() -> str:
|
|
"""
|
|
Ensure the user is authenticated, prompting for login if needed.
|
|
|
|
Checks for existing token and triggers login flow if not found.
|
|
|
|
Returns:
|
|
The authentication token
|
|
|
|
Raises:
|
|
ValueError: If authentication fails after login attempt
|
|
"""
|
|
# First check if already authenticated
|
|
token = get_auth_token()
|
|
if token:
|
|
return token
|
|
|
|
# No token found - trigger login
|
|
print("\nNo OAuth token found. Starting login flow...")
|
|
|
|
if trigger_login():
|
|
# Re-check for token after login
|
|
token = get_auth_token()
|
|
if token:
|
|
return token
|
|
|
|
# Login failed or was cancelled
|
|
raise ValueError(
|
|
"Authentication required.\n\n"
|
|
"To authenticate:\n"
|
|
" 1. Run: claude\n"
|
|
" 2. Type: /login\n"
|
|
" 3. Press Enter to open browser\n"
|
|
" 4. Complete OAuth login in browser"
|
|
)
|