Files
Aperant/apps/backend/core/auth.py
T
Andy 11f8d572ff feat(auth): replace setup-token with embedded /login terminal flow (#1321)
* 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>
2026-01-19 20:04:47 +01:00

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"
)