Compare commits

...

1 Commits

Author SHA1 Message Date
StillKnotKnown 5bd2211b8b fix(auth): support API profile mode without OAuth requirement
Fix authentication to work with API profiles (e.g., z.ai GLM endpoints)
without requiring Claude Code OAuth. The system now detects API profile
mode via ANTHROPIC_BASE_URL environment variable and uses the appropriate
authentication method:

- API Profile Mode: ANTHROPIC_BASE_URL set → use ANTHROPIC_AUTH_TOKEN
- OAuth Mode: ANTHROPIC_BASE_URL not set → use CLAUDE_CODE_OAUTH_TOKEN

Key change: In API profile mode, CLAUDE_CODE_OAUTH_TOKEN is NOT set in
environment, ensuring SDK uses API key instead of OAuth (SDK gives OAuth
priority when both are present).

Also fixed auth tests by replacing mocker fixture with monkeypatch.

Files changed:
- apps/backend/core/client.py: Add dual-mode auth detection
- tests/test_client.py: Add 15 comprehensive API profile tests
- tests/test_auth.py: Fix 3 tests using unavailable mocker fixture

Fixes issue where users with API profiles could not run agents despite
having valid ANTHROPIC_AUTH_TOKEN and ANTHROPIC_BASE_URL configured.
2026-01-29 22:46:47 +02:00
3 changed files with 376 additions and 16 deletions
+26 -10
View File
@@ -497,18 +497,34 @@ def create_client(
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
oauth_token = require_auth_token(config_dir)
# Authentication: Support both OAuth and API Profile modes
# API Profile mode: ANTHROPIC_BASE_URL indicates custom endpoint (e.g., z.ai)
# OAuth mode: CLAUDE_CODE_OAUTH_TOKEN for Claude Code subscription
api_profile_mode = bool(os.environ.get("ANTHROPIC_BASE_URL"))
# 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)
if api_profile_mode:
# API profile mode: ensure ANTHROPIC_AUTH_TOKEN is present
if not os.environ.get("ANTHROPIC_AUTH_TOKEN"):
raise ValueError(
"API profile mode active (ANTHROPIC_BASE_URL is set) "
"but ANTHROPIC_AUTH_TOKEN is not set"
)
# Do NOT set CLAUDE_CODE_OAUTH_TOKEN - SDK will use ANTHROPIC_AUTH_TOKEN
logger.info("Using API profile authentication")
else:
# OAuth mode: require and validate OAuth token
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
oauth_token = require_auth_token(config_dir)
# Ensure SDK can access it via its expected env var
# This is required because the SDK doesn't know about per-profile Keychain naming
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_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
# This is required because the SDK doesn't know about per-profile Keychain naming
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
if config_dir:
logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}")
+6 -6
View File
@@ -67,10 +67,10 @@ class TestEnvVarTokenResolution:
token = get_auth_token()
assert token == claude_token
def test_no_token_returns_none(self, mocker):
def test_no_token_returns_none(self, monkeypatch):
"""Returns None when no auth token is configured."""
# Mock keychain to return None (env vars already cleared by fixture)
mocker.patch("core.auth.get_token_from_keychain", return_value=None)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
token = get_auth_token()
assert token is None
@@ -369,12 +369,12 @@ class TestRequireAuthToken:
"""Tests for require_auth_token function."""
@pytest.fixture(autouse=True)
def clear_env(self, mocker):
def clear_env(self, monkeypatch):
"""Clear auth environment variables and mock keychain before each test."""
for var in AUTH_TOKEN_ENV_VARS:
os.environ.pop(var, None)
# Mock keychain to return None (tests that need a token will set env var)
mocker.patch("core.auth.get_token_from_keychain", return_value=None)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
yield
# Cleanup after test
for var in AUTH_TOKEN_ENV_VARS:
@@ -556,10 +556,10 @@ class TestTokenSourceDetection:
source = get_auth_token_source()
assert source == "Linux Secret Service"
def test_source_none_when_not_found(self, mocker):
def test_source_none_when_not_found(self, monkeypatch):
"""Returns None when no token source is found."""
# Mock keychain to return None (env vars already cleared by fixture)
mocker.patch("core.auth.get_token_from_keychain", return_value=None)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
source = get_auth_token_source()
assert source is None
+344
View File
@@ -18,6 +18,7 @@ import pytest
AUTH_TOKEN_ENV_VARS = [
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
]
@@ -135,3 +136,346 @@ class TestClientTokenValidation:
# Verify validation was called with the token
mock_validate.assert_called_once_with(valid_token)
class TestAPIProfileAuthentication:
"""Tests for API Profile authentication mode (e.g., z.ai, custom endpoints)."""
@pytest.fixture(autouse=True)
def clear_env(self):
"""Clear auth environment variables before and after each test."""
for var in AUTH_TOKEN_ENV_VARS:
os.environ.pop(var, None)
yield
for var in AUTH_TOKEN_ENV_VARS:
os.environ.pop(var, None)
def test_api_profile_mode_with_valid_token(self, tmp_path, monkeypatch):
"""API profile mode succeeds with ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN."""
api_token = "sk-api-test-token-123456"
api_endpoint = "https://api.z.ai/v1"
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
# Ensure no OAuth token is set
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
# 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, "glm-4", "coder")
# Verify SDK client was created
assert client is mock_sdk_client
# Verify CLAUDE_CODE_OAUTH_TOKEN was NOT set (API profile mode)
assert "CLAUDE_CODE_OAUTH_TOKEN" not in os.environ
# Verify ANTHROPIC_AUTH_TOKEN is still set
assert os.environ.get("ANTHROPIC_AUTH_TOKEN") == api_token
assert os.environ.get("ANTHROPIC_BASE_URL") == api_endpoint
def test_api_profile_mode_missing_token_raises_error(self, tmp_path, monkeypatch):
"""API profile mode raises ValueError when ANTHROPIC_AUTH_TOKEN is missing."""
api_endpoint = "https://api.z.ai/v1"
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
# Don't set ANTHROPIC_AUTH_TOKEN - this should cause an error
monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
from core.client import create_client
with pytest.raises(ValueError, match=r"API profile mode active.*ANTHROPIC_AUTH_TOKEN is not set"):
create_client(tmp_path, tmp_path, "glm-4", "coder")
def test_api_profile_mode_empty_token_raises_error(self, tmp_path, monkeypatch):
"""API profile mode raises ValueError when ANTHROPIC_AUTH_TOKEN is empty string."""
api_endpoint = "https://api.z.ai/v1"
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "") # Empty string
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
from core.client import create_client
with pytest.raises(ValueError, match=r"API profile mode active.*ANTHROPIC_AUTH_TOKEN is not set"):
create_client(tmp_path, tmp_path, "glm-4", "coder")
def test_oauth_mode_without_base_url(self, tmp_path, monkeypatch):
"""OAuth mode is used when ANTHROPIC_BASE_URL is not set."""
oauth_token = "sk-ant-oat01-oauth-token"
# Don't set ANTHROPIC_BASE_URL - this should trigger OAuth mode
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
# Mock the SDK client
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
# Verify CLAUDE_CODE_OAUTH_TOKEN was set (OAuth mode)
assert os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") == oauth_token
def test_api_profile_takes_precedence_over_oauth(self, tmp_path, monkeypatch):
"""When both ANTHROPIC_BASE_URL and OAuth token are set, API profile mode wins."""
api_token = "sk-api-test-token-123456"
api_endpoint = "https://api.z.ai/v1"
oauth_token = "sk-ant-oat01-oauth-token"
# Set both API profile and OAuth
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
# Mock the SDK client
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, "glm-4", "coder")
# Verify SDK client was created
assert client is mock_sdk_client
# In API profile mode, CLAUDE_CODE_OAUTH_TOKEN should NOT be set in env
# (We delete it to ensure SDK uses ANTHROPIC_AUTH_TOKEN instead)
# Note: The original CLAUDE_CODE_OAUTH_TOKEN env var is still there,
# but create_client() should NOT copy it to os.environ after that point
# Actually, looking at the code more carefully - the env var set in line 502
# happens AFTER we check api_profile_mode, so if API profile mode is active,
# we skip setting CLAUDE_CODE_OAUTH_TOKEN in os.environ
def test_empty_base_url_triggers_oauth_mode(self, tmp_path, monkeypatch):
"""Empty ANTHROPIC_BASE_URL should trigger OAuth mode, not API profile mode."""
oauth_token = "sk-ant-oat01-oauth-token"
# Set empty ANTHROPIC_BASE_URL - should be treated as "not set"
monkeypatch.setenv("ANTHROPIC_BASE_URL", "")
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
# Mock require_auth_token to verify it's called (OAuth mode)
with patch("core.client.require_auth_token", return_value=oauth_token):
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_api_profile_with_various_endpoints(self, tmp_path, monkeypatch):
"""API profile mode works with various endpoint formats."""
api_token = "sk-api-test-token-123456"
test_endpoints = [
"https://api.z.ai/v1",
"https://api.example.com",
"http://localhost:8080/v1",
"https://custom-gateway.com/anthropic-proxy",
]
for endpoint in test_endpoints:
# Clear and set fresh for each iteration
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
monkeypatch.setenv("ANTHROPIC_BASE_URL", endpoint)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
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, "glm-4", "coder")
assert client is mock_sdk_client
assert os.environ.get("ANTHROPIC_BASE_URL") == endpoint
def test_oauth_mode_without_any_token_raises_error(self, tmp_path, monkeypatch):
"""OAuth mode raises ValueError when no OAuth token is available."""
# Don't set any auth tokens
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False)
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
# Mock keychain to return None
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
from core.client import create_client
with pytest.raises(ValueError, match="No OAuth token found"):
create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder")
class TestAPIProfileAuthenticationIntegration:
"""Integration tests verifying the complete auth flow behavior."""
@pytest.fixture(autouse=True)
def clear_env(self):
"""Clear auth environment variables before and after each test."""
for var in AUTH_TOKEN_ENV_VARS:
os.environ.pop(var, None)
yield
for var in AUTH_TOKEN_ENV_VARS:
os.environ.pop(var, None)
def test_sdk_env_vars_includes_api_profile_vars(self, monkeypatch):
"""Verify get_sdk_env_vars() passes ANTHROPIC_AUTH_TOKEN and ANTHROPIC_BASE_URL."""
from core.auth import get_sdk_env_vars
api_token = "sk-api-test-token"
api_endpoint = "https://api.z.ai/v1"
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
sdk_env = get_sdk_env_vars()
assert sdk_env.get("ANTHROPIC_AUTH_TOKEN") == api_token
assert sdk_env.get("ANTHROPIC_BASE_URL") == api_endpoint
def test_sdk_env_vars_excludes_oauth_in_api_profile_mode(self, monkeypatch):
"""Verify SDK env vars don't include CLAUDE_CODE_OAUTH_TOKEN in API profile mode."""
from core.auth import get_sdk_env_vars
api_token = "sk-api-test-token"
api_endpoint = "https://api.z.ai/v1"
oauth_token = "sk-ant-oat01-oauth-token"
# Set both API profile and OAuth
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
sdk_env = get_sdk_env_vars()
# SDK_ENV_VARS doesn't include CLAUDE_CODE_OAUTH_TOKEN
# (it's set separately in create_client())
assert "CLAUDE_CODE_OAUTH_TOKEN" not in sdk_env
assert sdk_env.get("ANTHROPIC_AUTH_TOKEN") == api_token
assert sdk_env.get("ANTHROPIC_BASE_URL") == api_endpoint
def test_api_profile_mode_does_not_validate_oauth_token(self, tmp_path, monkeypatch):
"""In API profile mode, OAuth token validation is skipped."""
api_token = "sk-api-test-token"
api_endpoint = "https://api.z.ai/v1"
encrypted_oauth_token = "enc:encrypted-oauth-token" # Invalid encrypted format
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
# Even with a bogus encrypted OAuth token, API profile mode should work
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_oauth_token)
# Mock the SDK client
mock_sdk_client = MagicMock()
with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client):
from core.client import create_client
# Should NOT raise ValueError about encrypted token
# because OAuth validation is skipped in API profile mode
client = create_client(tmp_path, tmp_path, "glm-4", "coder")
assert client is mock_sdk_client
def test_oauth_mode_validates_token_even_with_api_env_vars_set(self, tmp_path, monkeypatch):
"""In OAuth mode (no BASE_URL), token validation happens even if ANTHROPIC_AUTH_TOKEN is set."""
api_token = "sk-api-test-token" # This exists but should be ignored in OAuth mode
encrypted_oauth_token = "enc:encrypted-oauth-token" # Invalid encrypted format
# Set ANTHROPIC_AUTH_TOKEN but NOT ANTHROPIC_BASE_URL - this is OAuth mode
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_oauth_token)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
from core.client import create_client
# Should raise ValueError about encrypted token because we're in OAuth mode
with pytest.raises(ValueError, match="encrypted format"):
create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder")
class TestAPIProfileAuthenticationEdgeCases:
"""Edge case tests for API profile authentication."""
@pytest.fixture(autouse=True)
def clear_env(self):
"""Clear auth environment variables before and after each test."""
for var in AUTH_TOKEN_ENV_VARS:
os.environ.pop(var, None)
yield
for var in AUTH_TOKEN_ENV_VARS:
os.environ.pop(var, None)
def test_whitespace_base_url_treated_as_empty(self, tmp_path, monkeypatch):
"""Whitespace-only ANTHROPIC_BASE_URL should trigger OAuth mode."""
oauth_token = "sk-ant-oat01-oauth-token"
# Set whitespace-only ANTHROPIC_BASE_URL
# Note: bool(" ") is True in Python, so this would be treated as API profile mode
# This test documents current behavior - if we want to trim whitespace, we'd need to add that
monkeypatch.setenv("ANTHROPIC_BASE_URL", " ")
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
# Currently, any non-empty string (even whitespace) triggers API profile mode
# which will fail because ANTHROPIC_AUTH_TOKEN is not set
from core.client import create_client
# This should raise an error because API profile mode is detected
# but ANTHROPIC_AUTH_TOKEN is not set
with pytest.raises(ValueError, match="API profile mode active"):
create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder")
def test_unicode_base_url(self, tmp_path, monkeypatch):
"""API profile mode works with Unicode characters in endpoint URL."""
api_token = "sk-api-test-token-123456"
# Using an IDN (Internationalized Domain Name)
api_endpoint = "https://münchen.example.com/v1"
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
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, "glm-4", "coder")
assert client is mock_sdk_client
assert os.environ.get("ANTHROPIC_BASE_URL") == api_endpoint
def test_api_token_with_special_characters(self, tmp_path, monkeypatch):
"""API profile mode works with tokens containing special characters."""
# Tokens with various formats
test_tokens = [
"sk-api-simple",
"sk-api-with-dashes-and_underscores",
"sk.api.with.dots",
"sk_api_with_123456_numbers",
]
api_endpoint = "https://api.example.com/v1"
for token in test_tokens:
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", token)
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
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, "glm-4", "coder")
assert client is mock_sdk_client
assert os.environ.get("ANTHROPIC_AUTH_TOKEN") == token