diff --git a/apps/backend/INVESTIGATION.md b/apps/backend/INVESTIGATION.md new file mode 100644 index 00000000..699787ce --- /dev/null +++ b/apps/backend/INVESTIGATION.md @@ -0,0 +1,421 @@ +# Token Encryption Investigation + +## Issue Summary + +Auto-Claude users are experiencing API 401 errors ("Invalid bearer token") because the Python backend is passing encrypted tokens (with `enc:` prefix) directly to the Claude Agent SDK without decryption. Standalone Claude Code terminals work correctly because they decrypt these tokens before use. + +**Key insight from user thehaffk:** "python cant unencrypt claude token and it launches session with CLAUDE_CODE_OAUTH_TOKEN=enc:djEwtxMGISt3tQ..." + +## Token Storage Format + +### Encrypted Token Format + +Claude Code CLI stores OAuth tokens in an encrypted format with the prefix `enc:`: + +```text +enc:djEwtxMGISt3tQ... +``` + +This format is used when tokens are stored in: +- **macOS**: Keychain (service: "Claude Code-credentials") +- **Linux**: Secret Service API (DBus, via secretstorage library) +- **Windows**: Credential Manager / .credentials.json files + +### Decrypted Token Format + +Valid Claude OAuth tokens have the format: +```text +sk-ant-oat01-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +``` + +## Current Token Flow (BROKEN) + +1. **Token Storage**: Claude Code CLI stores encrypted token with `enc:` prefix in system keychain +2. **Token Retrieval**: `apps/backend/core/auth.py::get_auth_token()` retrieves token from: + - Environment variable `CLAUDE_CODE_OAUTH_TOKEN` + - OR system keychain via `get_token_from_keychain()` +3. **❌ NO DECRYPTION**: Token is returned as-is with `enc:` prefix intact +4. **SDK Initialization**: Encrypted token passed to Claude Agent SDK +5. **API Call Fails**: SDK sends encrypted token to API → 401 error + +### Proof of Broken Flow + +Test in `apps/backend`: +```python +import os +os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = 'enc:test123' + +from core.auth import get_auth_token +token = get_auth_token() +print(f"Token: {token}") # Output: "enc:test123" +print(f"Encrypted: {token.startswith('enc:')}") # Output: True +``` + +## How Standalone Claude Code CLI Handles Tokens + +### Current Understanding + +1. **Token Detection**: CLI checks if token starts with `enc:` prefix +2. **Decryption**: If encrypted, CLI decrypts using platform-specific keyring access +3. **Authentication**: Decrypted `sk-ant-oat01-` token is used for API calls + +### Missing Documentation + +Web search for "Claude Code CLI encrypted token enc: prefix decryption" found: +- Token storage formats (JSON with accessToken, refreshToken, expiresAt) +- Security issues (tokens exposed in debug logs before v2.1.0) +- Keychain access patterns for macOS/Linux/Windows + +**❌ NOT FOUND**: Specific documentation on how Claude Code CLI decrypts `enc:` tokens + +Sources: +- [Claude Code CLI over SSH on macOS: Fixing Keychain Access](https://phoenixtrap.com/2025/10/26/claude-code-cli-over-ssh-on-macos-fixing-keychain-access/) +- [Identity and Access Management - Claude Code Docs](https://code.claude.com/docs/en/iam) +- [Claude Code sessions should be encrypted | yoav.blog](https://yoav.blog/2026/01/09/claude-code-sessions-should-be-encrypted/) + +## Decryption Approach Options + +### Option 1: Claude Agent SDK Built-in Decryption + +**Status**: NEEDS VERIFICATION + +The Claude Agent SDK (`claude-agent-sdk>=0.1.19`) may handle decryption internally if: +- Token is passed to SDK still encrypted +- SDK detects `enc:` prefix +- SDK has access to system keyring for decryption + +**Action Required**: Check if SDK has decryption capabilities by examining: +- SDK source code or documentation +- Whether SDK expects encrypted vs decrypted tokens +- If SDK requires specific environment variables for decryption + +### Option 2: Python Backend Decryption (Recommended) + +**Approach**: Implement decryption in `apps/backend/core/auth.py` before passing to SDK + +**Implementation Pattern**: +```python +def get_auth_token() -> str | None: + """Get authentication token (decrypted if necessary).""" + token = _retrieve_token_from_sources() # From env or keychain + + if token and token.startswith("enc:"): + # Decrypt the token + token = decrypt_token(token) + + return token + +def decrypt_token(encrypted_token: str) -> str: + """ + Decrypt Claude Code encrypted token. + + Args: + encrypted_token: Token with 'enc:' prefix + + Returns: + Decrypted token in format 'sk-ant-oat01-...' + """ + # Remove 'enc:' prefix + encrypted_data = encrypted_token[4:] + + # TODO: Implement decryption logic + # Questions to answer: + # 1. What encryption algorithm does Claude Code use? + # 2. Where is the decryption key stored? + # 3. Is the decryption key platform-specific (per-user)? + # 4. Can we reuse Claude Code's decryption mechanism? + + raise NotImplementedError("Token decryption not yet implemented") +``` + +### Option 3: Call Claude Code CLI for Decryption + +**Approach**: Use the Claude Code CLI binary to decrypt tokens + +```python +def decrypt_token(encrypted_token: str) -> str: + """Decrypt token by invoking Claude Code CLI.""" + # Find claude binary + claude_path = shutil.which("claude") or "~/.local/bin/claude" + + # Use CLI command to get decrypted token + # (if such a command exists - needs research) + result = subprocess.run( + [claude_path, "auth", "decrypt", encrypted_token], + capture_output=True, + text=True + ) + + return result.stdout.strip() +``` + +**Issues**: +- Requires Claude Code CLI to be installed +- No documented CLI command for token decryption +- Adds external dependency + +## Required Investigation Steps + +### 1. Verify SDK Decryption Capabilities + +**Task**: Check if `claude-agent-sdk` handles `enc:` tokens automatically + +**Method**: +```bash +# In environment with SDK installed +python3 << 'EOF' +import os +os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = 'enc:...' # Real encrypted token + +from claude_agent_sdk import Client +# Try creating client - does it decrypt internally? +client = Client() +# Check if authentication works +EOF +``` + +### 2. Reverse Engineer Claude Code CLI Decryption + +**Task**: Understand how Claude CLI decrypts tokens + +**Method**: +- Examine Claude CLI binary (if possible) +- Trace system calls when CLI runs (strace on Linux, dtruss on macOS) +- Check if CLI accesses specific keychain entries for decryption keys +- Look for encryption/decryption libraries used by CLI + +### 3. Find Decryption Key Storage + +**Task**: Locate where decryption keys are stored + +**Hypothesis**: Decryption key stored in: +- macOS: Keychain (separate entry from encrypted token) +- Linux: Secret Service API +- Windows: Credential Manager + +**Verification**: +```bash +# macOS: List all keychain entries +security find-generic-password -a "$(whoami)" | grep -i claude + +# Linux: Use secretstorage to list all items +python3 -c "import secretstorage; ..." +``` + +## Recommended Decryption Approach for Python Backend + +Based on investigation so far, the recommended approach is: + +1. **Detect encrypted tokens**: Check for `enc:` prefix in `get_auth_token()` +2. **Decrypt before use**: Implement `decrypt_token()` function +3. **Platform-specific decryption**: Use appropriate keyring library: + - macOS: Use `subprocess` with `/usr/bin/security` to access decryption key + - Linux: Use `secretstorage` library to access Secret Service API + - Windows: Access Credential Manager or credentials.json +4. **Backward compatibility**: Support both encrypted and plaintext tokens +5. **Error handling**: Provide clear error messages if decryption fails + +## Complete Token Flow Trace (Frontend → Backend) + +### 1. Token Retrieval (Frontend) + +**File**: `apps/frontend/src/main/services/profile-service.ts` + +The frontend retrieves the OAuth token from the system keychain but **does not decrypt it**. When no API profile is active (OAuth mode), the frontend returns an empty environment object, which means it relies on: +- The token already being in the environment as `CLAUDE_CODE_OAUTH_TOKEN` +- OR the Python backend retrieving it from the keychain + +**Key Code**: +```typescript +// Line 223: Returns empty object in OAuth mode, allowing +// CLAUDE_CODE_OAUTH_TOKEN to be used from system keychain +``` + +### 2. Environment Variable Passing (Frontend → PTY) + +**File**: `apps/frontend/src/main/terminal/pty-manager.ts` + +The PTY manager spawns the terminal shell with environment variables, including `CLAUDE_CODE_OAUTH_TOKEN`: + +**Key Code** (Lines 149-152): +```typescript +// Remove ANTHROPIC_API_KEY to ensure Claude Code uses OAuth tokens +// (CLAUDE_CODE_OAUTH_TOKEN from profileEnv) instead of API keys +const { DEBUG: _DEBUG, ANTHROPIC_API_KEY: _ANTHROPIC_API_KEY, ...cleanEnv } = process.env; +``` + +**Important**: The frontend passes through whatever token value exists in the environment - it does NOT check for `enc:` prefix or decrypt it. + +### 3. Token Retrieval (Backend) + +**File**: `apps/backend/core/auth.py` + +#### 3.1. get_auth_token() + +This function retrieves the token from multiple sources: + +```python +def get_auth_token() -> str | None: + # First check environment variables + for var in AUTH_TOKEN_ENV_VARS: # CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_AUTH_TOKEN + token = os.environ.get(var) + if token: + return token # ❌ Returns immediately without checking for enc: prefix + + # Fallback to system credential store + return get_token_from_keychain() # ❌ Also returns without decryption +``` + +**Issue**: Returns token as-is with `enc:` prefix intact. + +#### 3.2. require_auth_token() + +This function calls `get_auth_token()` and raises an error if no token is found: + +```python +def require_auth_token() -> str: + token = get_auth_token() # ❌ Gets encrypted token + if not token: + raise ValueError("No OAuth token found...") + return token # ❌ Returns encrypted token +``` + +**Issue**: No decryption step between retrieval and return. + +#### 3.3. ensure_claude_code_oauth_token() + +This function ensures the environment variable is set: + +```python +def ensure_claude_code_oauth_token() -> None: + if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"): + return + + token = get_auth_token() # ❌ Gets encrypted token + if token: + os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token # ❌ Sets encrypted token +``` + +**Issue**: Propagates encrypted token to environment variable. + +### 4. Token Usage in SDK Client Creation (Backend) + +#### 4.1. Full Client Creation + +**File**: `apps/backend/core/client.py` (see `create_client()` function) + +```python +def create_client(...): + oauth_token = require_auth_token() # ❌ Gets encrypted token + # Ensure SDK can access it via its expected env var + os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token # ❌ Sets encrypted token +``` + +**Issue**: Encrypted token is passed to the Claude Agent SDK, which expects a decrypted `sk-ant-oat01-` token. + +#### 4.2. Simple Client Creation + +**File**: `apps/backend/core/simple_client.py` (see `create_simple_client()` function) + +```python +def create_simple_client(...): + # Get authentication + oauth_token = require_auth_token() # ❌ Gets encrypted token + import os + os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token # ❌ Sets encrypted token +``` + +**Issue**: Same problem - encrypted token passed to SDK. + +#### 4.3. Other Usages + +**Files**: +- `apps/backend/core/workspace.py` (Line 1966) - AI merge operations +- `apps/backend/runners/insights_runner.py` - Insights analysis +- `apps/backend/runners/github/batch_issues.py` - GitHub batch operations +- `apps/backend/integrations/linear/updater.py` - Linear integration +- `apps/backend/commit_message.py` - Commit message generation +- `apps/backend/analysis/insight_extractor.py` - Code insights +- `apps/backend/merge/ai_resolver/claude_client.py` - Merge resolution + +**All follow the same pattern**: Call `ensure_claude_code_oauth_token()` → encrypted token in environment → SDK receives encrypted token → API 401 error. + +### 5. Where Decryption Should Be Inserted + +Based on the flow analysis, decryption should be added at **the earliest point of token retrieval** to avoid duplicating decryption logic: + +**RECOMMENDED INSERTION POINT**: `apps/backend/core/auth.py::get_auth_token()` + +```python +def get_auth_token() -> str | None: + # First check environment variables + for var in AUTH_TOKEN_ENV_VARS: + token = os.environ.get(var) + if token: + # ✅ INSERT DECRYPTION HERE + if token.startswith("enc:"): + token = decrypt_token(token) + return token + + # Fallback to system credential store + token = get_token_from_keychain() + # ✅ ALSO DECRYPT KEYCHAIN TOKENS + if token and token.startswith("enc:"): + token = decrypt_token(token) + return token +``` + +**Benefits of this approach**: +1. Single location for decryption logic +2. All downstream functions automatically get decrypted tokens +3. Backward compatible (plaintext tokens pass through unchanged) +4. Consistent behavior across all token sources (env vars and keychain) + +**Alternative insertion points** (NOT recommended): +- `require_auth_token()` - Would need similar logic in `get_auth_token()` for non-required usage +- `create_client()` - Would need duplication in `create_simple_client()` and all other clients +- `ensure_claude_code_oauth_token()` - Would miss direct `get_auth_token()` calls + +## Next Steps + +1. ✅ Document current token flow and identify issue (THIS FILE - COMPLETED) +2. ✅ Trace token flow from frontend to backend (THIS FILE - COMPLETED) +3. ✅ Identify where decryption should be inserted (THIS FILE - COMPLETED) +4. ⏳ Verify if Claude Agent SDK handles decryption internally +5. ⏳ Reverse engineer or document Claude Code CLI decryption mechanism +6. ⏳ Implement `decrypt_token()` function in `apps/backend/core/auth.py` +7. ⏳ Add encryption detection and auto-decryption to `get_auth_token()` +8. ⏳ Test with real encrypted tokens on macOS and Linux +9. ⏳ Add comprehensive error handling for decryption failures + +## Open Questions + +1. **What encryption algorithm does Claude Code use for `enc:` tokens?** + - Possible: AES-256, ChaCha20, or similar + - Key derivation method? + +2. **Where is the decryption key stored?** + - Same keychain entry as encrypted token? + - Separate keychain entry? + - Derived from system/user credentials? + +3. **Does Claude Agent SDK expect encrypted or decrypted tokens?** + - If it expects decrypted: we must decrypt before passing + - If it handles encryption: we may be missing SDK configuration + +4. **Is there a Claude Code CLI command to decrypt tokens?** + - `claude auth decrypt `? + - `claude auth get-token`? + - No documented command found in research + +5. **Can we reuse Claude Code's decryption mechanism?** + - Import decryption functions from CLI? + - Call CLI as subprocess? + - Implement decryption ourselves? + +## References + +- Issue: [GitHub #1223: API Error 401](https://github.com/AndyMik90/Auto-Claude/issues/1223) +- Current auth implementation: `apps/backend/core/auth.py` +- SDK client initialization: `apps/backend/core/client.py` +- Requirements: `apps/backend/requirements.txt` (includes `secretstorage>=3.3.3` for Linux) diff --git a/apps/backend/core/auth.py b/apps/backend/core/auth.py index cbd46efa..e1bf1f74 100644 --- a/apps/backend/core/auth.py +++ b/apps/backend/core/auth.py @@ -7,11 +7,21 @@ for custom API endpoints. """ import json +import logging import os -import platform 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: @@ -52,6 +62,257 @@ SDK_ENV_VARS = [ ] +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. @@ -64,11 +325,9 @@ def get_token_from_keychain() -> str | None: Returns: Token string if found, None otherwise """ - system = platform.system() - - if system == "Darwin": + if is_macos(): return _get_token_from_macos_keychain() - elif system == "Windows": + elif is_windows(): return _get_token_from_windows_credential_files() else: # Linux: use secret-service API via DBus @@ -230,6 +489,9 @@ def get_auth_token() -> str | None: 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 """ @@ -237,10 +499,27 @@ def get_auth_token() -> str | None: 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 - return get_token_from_keychain() + 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: @@ -252,10 +531,9 @@ def get_auth_token_source() -> str | None: # Check if token came from system credential store if get_token_from_keychain(): - system = platform.system() - if system == "Darwin": + if is_macos(): return "macOS Keychain" - elif system == "Windows": + elif is_windows(): return "Windows Credential Files" else: return "Linux Secret Service" @@ -278,15 +556,14 @@ def require_auth_token() -> str: "Direct API keys (ANTHROPIC_API_KEY) are not supported.\n\n" ) # Provide platform-specific guidance - system = platform.system() - if system == "Darwin": + if is_macos(): error_msg += ( "To authenticate:\n" " 1. Run: claude setup-token\n" " 2. The token will be saved to macOS Keychain automatically\n\n" "Or set CLAUDE_CODE_OAUTH_TOKEN in your .env file." ) - elif system == "Windows": + elif is_windows(): error_msg += ( "To authenticate:\n" " 1. Run: claude setup-token\n" @@ -317,7 +594,7 @@ def _find_git_bash_path() -> str | None: Returns: Full path to bash.exe if found, None otherwise """ - if platform.system() != "Windows": + if not is_windows(): return None # If already set in environment, use that @@ -405,7 +682,7 @@ def get_sdk_env_vars() -> dict[str, str]: # On Windows, auto-detect git-bash path if not already set # Claude Code CLI requires bash.exe to run on Windows - if platform.system() == "Windows" and "CLAUDE_CODE_GIT_BASH_PATH" not in env: + 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 diff --git a/apps/backend/core/client.py b/apps/backend/core/client.py index 807d1295..fc59a500 100644 --- a/apps/backend/core/client.py +++ b/apps/backend/core/client.py @@ -357,7 +357,11 @@ from agents.tools_pkg import ( ) from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from claude_agent_sdk.types import HookMatcher -from core.auth import get_sdk_env_vars, require_auth_token +from core.auth import ( + get_sdk_env_vars, + require_auth_token, + validate_token_not_encrypted, +) from linear_updater import is_linear_enabled from prompts_pkg.project_context import detect_project_capabilities, load_project_index from security import bash_security_hook @@ -706,6 +710,12 @@ def create_client( 4. Tool filtering - Each agent type only sees relevant tools (prevents misuse) """ oauth_token = require_auth_token() + + # Validate token is not encrypted before passing to SDK + # Encrypted tokens (enc:...) should have been decrypted by require_auth_token() + # If we still have an encrypted token here, it means decryption failed or was skipped + validate_token_not_encrypted(oauth_token) + # Ensure SDK can access it via its expected env var os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token diff --git a/apps/backend/core/simple_client.py b/apps/backend/core/simple_client.py index 59e88a45..6f643e26 100644 --- a/apps/backend/core/simple_client.py +++ b/apps/backend/core/simple_client.py @@ -25,7 +25,11 @@ from pathlib import Path from agents.tools_pkg import get_agent_config, get_default_thinking_level from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient -from core.auth import get_sdk_env_vars, require_auth_token +from core.auth import ( + get_sdk_env_vars, + require_auth_token, + validate_token_not_encrypted, +) from core.client import find_claude_cli from phase_config import get_thinking_budget @@ -67,6 +71,12 @@ def create_simple_client( """ # Get authentication oauth_token = require_auth_token() + + # Validate token is not encrypted before passing to SDK + # Encrypted tokens (enc:...) should have been decrypted by require_auth_token() + # If we still have an encrypted token here, it means decryption failed or was skipped + validate_token_not_encrypted(oauth_token) + import os os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token diff --git a/tests/test_auth.py b/tests/test_auth.py index fe840ed8..ee49cbc2 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -612,3 +612,124 @@ class TestSdkEnvVars: env = get_sdk_env_vars() assert env["CLAUDE_CODE_GIT_BASH_PATH"] == existing_path + + +class TestTokenDecryption: + """Tests for token decryption functionality.""" + + def test_is_encrypted_token_detects_prefix(self): + """Verify is_encrypted_token() detects enc: prefix.""" + from core.auth import is_encrypted_token + + assert is_encrypted_token("enc:test123") + assert is_encrypted_token("enc:djEwtxMGISt3tQ") + assert not is_encrypted_token("sk-ant-oat01-test") + assert not is_encrypted_token("") + assert not is_encrypted_token(None) + + def test_decrypt_token_validates_format(self): + """Verify decrypt_token() validates token format.""" + from core.auth import decrypt_token + + with pytest.raises(ValueError, match="Invalid encrypted token format"): + decrypt_token("sk-ant-oat01-test") + + def test_decrypt_token_handles_short_data(self): + """Verify decrypt_token() rejects short encrypted data.""" + from core.auth import decrypt_token + + with pytest.raises(ValueError, match="too short"): + decrypt_token("enc:abc") + + def test_get_auth_token_decrypts_encrypted_env_token(self, monkeypatch): + """Verify get_auth_token() attempts to decrypt encrypted tokens from env.""" + from unittest.mock import patch + + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "enc:testtoken123456789") + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + with patch("core.auth.decrypt_token") as mock_decrypt: + # Simulate decryption failure + mock_decrypt.side_effect = ValueError("Decryption not implemented") + + from core.auth import get_auth_token + + result = get_auth_token() + + # Verify decrypt_token was called with the encrypted token + mock_decrypt.assert_called_once_with("enc:testtoken123456789") + # Verify the encrypted token is returned on decryption failure + assert result == "enc:testtoken123456789" + + def test_get_auth_token_returns_decrypted_token_on_success(self, monkeypatch): + """Verify get_auth_token() returns decrypted token when decryption succeeds.""" + from unittest.mock import patch + + encrypted_token = "enc:testtoken123456789" + decrypted_token = "sk-ant-oat01-decrypted-token" + + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_token) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + with patch("core.auth.decrypt_token") as mock_decrypt: + mock_decrypt.return_value = decrypted_token + + from core.auth import get_auth_token + + result = get_auth_token() + + # Verify decrypt_token was called + mock_decrypt.assert_called_once_with(encrypted_token) + # Verify the decrypted token is returned + assert result == decrypted_token + + def test_backward_compatibility_plaintext_tokens(self, monkeypatch): + """Verify plaintext tokens continue to work unchanged.""" + token = "sk-ant-oat01-test" + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", token) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + from core.auth import get_auth_token + + result = get_auth_token() + assert result == token + + +class TestValidateTokenNotEncrypted: + """Tests for validate_token_not_encrypted function.""" + + def test_validate_token_not_encrypted_raises_for_encrypted(self): + """Verify validate_token_not_encrypted() raises ValueError for encrypted tokens.""" + from core.auth import validate_token_not_encrypted + + with pytest.raises(ValueError, match="encrypted format"): + validate_token_not_encrypted("enc:test123456789012") + + def test_validate_token_not_encrypted_raises_with_helpful_message(self): + """Verify validate_token_not_encrypted() provides helpful error message.""" + from core.auth import validate_token_not_encrypted + + with pytest.raises(ValueError) as exc_info: + validate_token_not_encrypted("enc:test123456789012") + + error_msg = str(exc_info.value) + assert "claude setup-token" in error_msg + assert "CLAUDE_CODE_OAUTH_TOKEN" in error_msg + assert "plaintext token" in error_msg + + def test_validate_token_not_encrypted_accepts_plaintext(self): + """Verify validate_token_not_encrypted() accepts plaintext tokens without raising.""" + from core.auth import validate_token_not_encrypted + + # Should not raise for valid plaintext tokens + validate_token_not_encrypted("sk-ant-oat01-test-token") + validate_token_not_encrypted("sk-ant-api01-test-token") + validate_token_not_encrypted("any-other-plaintext-token") + + def test_validate_token_not_encrypted_accepts_empty_prefix(self): + """Verify validate_token_not_encrypted() accepts tokens without enc: prefix.""" + from core.auth import validate_token_not_encrypted + + # Token that starts with 'enc' but not 'enc:' should be accepted + validate_token_not_encrypted("encrypted-looking-but-not") + validate_token_not_encrypted("enctest") diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 00000000..ca00ad2b --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +Tests for Client Creation and Token Validation +=============================================== + +Tests the client.py and simple_client.py module functionality including: +- Token validation before SDK initialization +- Encrypted token rejection +- Client creation with valid tokens +""" + +from unittest.mock import MagicMock, patch + +import pytest + + +class TestClientTokenValidation: + """Tests for client token validation.""" + + def test_create_client_rejects_encrypted_tokens(self, tmp_path, monkeypatch): + """Verify create_client() rejects encrypted tokens.""" + from core.client import create_client + + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "enc:test123456789012") + # Mock keychain to ensure encrypted token is the only source + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + with pytest.raises(ValueError, match="encrypted format"): + create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder") + + def test_create_simple_client_rejects_encrypted_tokens(self, monkeypatch): + """Verify create_simple_client() rejects encrypted tokens.""" + from core.simple_client import create_simple_client + + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "enc:test123456789012") + # Mock keychain to ensure encrypted token is the only source + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + with pytest.raises(ValueError, match="encrypted format"): + create_simple_client(agent_type="merge_resolver") + + def test_create_client_accepts_valid_plaintext_token(self, tmp_path, monkeypatch): + """Verify create_client() accepts valid plaintext tokens and creates SDK client.""" + valid_token = "sk-ant-oat01-valid-plaintext-token" + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", valid_token) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + # Mock the SDK client to avoid actual initialization + mock_sdk_client = MagicMock() + with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client): + from core.client import create_client + + client = create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder") + + # Verify SDK client was created + assert client is mock_sdk_client + + def test_create_simple_client_accepts_valid_plaintext_token(self, monkeypatch): + """Verify create_simple_client() accepts valid plaintext tokens and creates SDK client.""" + valid_token = "sk-ant-oat01-valid-plaintext-token" + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", valid_token) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + # Mock the SDK client to avoid actual initialization + mock_sdk_client = MagicMock() + with patch( + "core.simple_client.ClaudeSDKClient", return_value=mock_sdk_client + ): + from core.simple_client import create_simple_client + + client = create_simple_client(agent_type="merge_resolver") + + # Verify SDK client was created + assert client is mock_sdk_client + + def test_create_client_validates_token_before_sdk_init( + self, tmp_path, monkeypatch + ): + """Verify create_client() validates token format before SDK initialization.""" + valid_token = "sk-ant-oat01-valid-token" + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", valid_token) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + # Mock validate_token_not_encrypted to verify it's called + with patch( + "core.client.validate_token_not_encrypted" + ) as mock_validate, patch("core.client.ClaudeSDKClient"): + from core.client import create_client + + create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder") + + # Verify validation was called with the token + mock_validate.assert_called_once_with(valid_token) + + def test_create_simple_client_validates_token_before_sdk_init(self, monkeypatch): + """Verify create_simple_client() validates token format before SDK initialization.""" + valid_token = "sk-ant-oat01-valid-token" + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", valid_token) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + # Mock validate_token_not_encrypted to verify it's called + with patch( + "core.simple_client.validate_token_not_encrypted" + ) as mock_validate, patch("core.simple_client.ClaudeSDKClient"): + from core.simple_client import create_simple_client + + create_simple_client(agent_type="merge_resolver") + + # Verify validation was called with the token + mock_validate.assert_called_once_with(valid_token)