vibecore 0.3.0b1__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of vibecore might be problematic. Click here for more details.
- vibecore/auth/__init__.py +15 -0
- vibecore/auth/config.py +38 -0
- vibecore/auth/interceptor.py +141 -0
- vibecore/auth/manager.py +173 -0
- vibecore/auth/models.py +54 -0
- vibecore/auth/oauth_flow.py +129 -0
- vibecore/auth/pkce.py +29 -0
- vibecore/auth/storage.py +111 -0
- vibecore/auth/token_manager.py +131 -0
- vibecore/cli.py +98 -9
- vibecore/handlers/stream_handler.py +11 -0
- vibecore/models/anthropic_auth.py +226 -0
- vibecore/settings.py +33 -2
- vibecore/widgets/tool_message_factory.py +8 -0
- vibecore/widgets/tool_messages.py +112 -0
- vibecore/widgets/tool_messages.tcss +69 -0
- {vibecore-0.3.0b1.dist-info → vibecore-0.3.1.dist-info}/METADATA +2 -1
- {vibecore-0.3.0b1.dist-info → vibecore-0.3.1.dist-info}/RECORD +21 -11
- vibecore-0.3.1.dist-info/entry_points.txt +2 -0
- vibecore-0.3.0b1.dist-info/entry_points.txt +0 -2
- {vibecore-0.3.0b1.dist-info → vibecore-0.3.1.dist-info}/WHEEL +0 -0
- {vibecore-0.3.0b1.dist-info → vibecore-0.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Anthropic Pro/Max authentication module."""
|
|
2
|
+
|
|
3
|
+
from vibecore.auth.config import ANTHROPIC_CONFIG
|
|
4
|
+
from vibecore.auth.manager import AnthropicAuthManager
|
|
5
|
+
from vibecore.auth.models import AnthropicAuth, ApiKeyCredentials, OAuthCredentials
|
|
6
|
+
from vibecore.auth.storage import SecureAuthStorage
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ANTHROPIC_CONFIG",
|
|
10
|
+
"AnthropicAuth",
|
|
11
|
+
"AnthropicAuthManager",
|
|
12
|
+
"ApiKeyCredentials",
|
|
13
|
+
"OAuthCredentials",
|
|
14
|
+
"SecureAuthStorage",
|
|
15
|
+
]
|
vibecore/auth/config.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Configuration constants for Anthropic authentication."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AnthropicConfig:
|
|
7
|
+
"""Configuration for Anthropic OAuth and API."""
|
|
8
|
+
|
|
9
|
+
# OAuth Client Configuration
|
|
10
|
+
OAUTH_CLIENT_ID: Final[str] = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
11
|
+
OAUTH_SCOPES: Final[str] = "org:create_api_key user:profile user:inference"
|
|
12
|
+
OAUTH_REDIRECT_URI: Final[str] = "https://console.anthropic.com/oauth/code/callback"
|
|
13
|
+
OAUTH_RESPONSE_TYPE: Final[str] = "code"
|
|
14
|
+
OAUTH_CODE_CHALLENGE_METHOD: Final[str] = "S256"
|
|
15
|
+
|
|
16
|
+
# API Endpoints
|
|
17
|
+
CLAUDE_AI_AUTHORIZE: Final[str] = "https://claude.ai/oauth/authorize"
|
|
18
|
+
CONSOLE_AUTHORIZE: Final[str] = "https://console.anthropic.com/oauth/authorize"
|
|
19
|
+
TOKEN_EXCHANGE: Final[str] = "https://console.anthropic.com/v1/oauth/token"
|
|
20
|
+
API_BASE: Final[str] = "https://api.anthropic.com"
|
|
21
|
+
MESSAGES: Final[str] = "https://api.anthropic.com/v1/messages"
|
|
22
|
+
|
|
23
|
+
# Beta Headers (Critical for Claude Code spoofing)
|
|
24
|
+
BETA_OAUTH: Final[str] = "oauth-2025-04-20"
|
|
25
|
+
BETA_CLAUDE_CODE: Final[str] = "claude-code-20250219" # CRITICAL: Identifies as Claude Code
|
|
26
|
+
BETA_INTERLEAVED_THINKING: Final[str] = "interleaved-thinking-2025-05-14"
|
|
27
|
+
BETA_FINE_GRAINED_STREAMING: Final[str] = "fine-grained-tool-streaming-2025-05-14"
|
|
28
|
+
|
|
29
|
+
# Token Management
|
|
30
|
+
TOKEN_REFRESH_BUFFER_SECONDS: Final[int] = 300 # Refresh 5 minutes before expiry
|
|
31
|
+
TOKEN_MAX_RETRY_ATTEMPTS: Final[int] = 3
|
|
32
|
+
TOKEN_RETRY_DELAY_MS: Final[int] = 1000
|
|
33
|
+
|
|
34
|
+
# Claude Code Identity
|
|
35
|
+
CLAUDE_CODE_IDENTITY: Final[str] = "You are Claude Code, Anthropic's official CLI for Claude."
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
ANTHROPIC_CONFIG = AnthropicConfig()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""HTTP request interceptor for Claude Code spoofing."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from httpx import URL
|
|
7
|
+
|
|
8
|
+
from vibecore.auth.config import ANTHROPIC_CONFIG
|
|
9
|
+
from vibecore.auth.storage import SecureAuthStorage
|
|
10
|
+
from vibecore.auth.token_manager import TokenRefreshManager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AnthropicRequestInterceptor:
|
|
14
|
+
"""Intercepts and modifies Anthropic API requests."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, storage: SecureAuthStorage):
|
|
17
|
+
"""
|
|
18
|
+
Initialize request interceptor.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
storage: Secure storage for credentials.
|
|
22
|
+
"""
|
|
23
|
+
self.storage = storage
|
|
24
|
+
self.token_manager = TokenRefreshManager(storage)
|
|
25
|
+
|
|
26
|
+
async def intercept_request(self, url: str, headers: dict[str, str] | None = None, **kwargs: Any) -> dict[str, Any]:
|
|
27
|
+
"""
|
|
28
|
+
Intercept and modify request for Anthropic API.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
url: Request URL.
|
|
32
|
+
headers: Request headers.
|
|
33
|
+
**kwargs: Additional request parameters.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Modified request parameters.
|
|
37
|
+
"""
|
|
38
|
+
auth = await self.storage.load("anthropic")
|
|
39
|
+
|
|
40
|
+
if not auth:
|
|
41
|
+
raise ValueError("Not authenticated with Anthropic")
|
|
42
|
+
|
|
43
|
+
# Prepare headers
|
|
44
|
+
headers = {} if headers is None else headers.copy()
|
|
45
|
+
|
|
46
|
+
if auth.type == "oauth": # OAuth auth
|
|
47
|
+
await self._configure_oauth_headers(headers)
|
|
48
|
+
else: # API key auth
|
|
49
|
+
self._configure_api_key_headers(headers, auth.key) # type: ignore
|
|
50
|
+
|
|
51
|
+
# Apply Claude Code spoofing headers
|
|
52
|
+
self._apply_claude_code_headers(headers)
|
|
53
|
+
|
|
54
|
+
# Return modified request parameters
|
|
55
|
+
return {**kwargs, "headers": headers}
|
|
56
|
+
|
|
57
|
+
async def _configure_oauth_headers(self, headers: dict[str, str]) -> None:
|
|
58
|
+
"""Configure headers for OAuth authentication."""
|
|
59
|
+
# Get valid access token (handles refresh automatically)
|
|
60
|
+
access_token = await self.token_manager.get_valid_token()
|
|
61
|
+
|
|
62
|
+
# Remove any API key headers (OAuth takes precedence)
|
|
63
|
+
headers.pop("x-api-key", None)
|
|
64
|
+
headers.pop("X-Api-Key", None)
|
|
65
|
+
headers.pop("anthropic-api-key", None)
|
|
66
|
+
|
|
67
|
+
# Set OAuth bearer token
|
|
68
|
+
headers["Authorization"] = f"Bearer {access_token}"
|
|
69
|
+
|
|
70
|
+
def _configure_api_key_headers(self, headers: dict[str, str], api_key: str) -> None:
|
|
71
|
+
"""Configure headers for API key authentication."""
|
|
72
|
+
# Remove OAuth headers if present
|
|
73
|
+
headers.pop("Authorization", None)
|
|
74
|
+
|
|
75
|
+
# Set API key
|
|
76
|
+
headers["x-api-key"] = api_key
|
|
77
|
+
|
|
78
|
+
def _apply_claude_code_headers(self, headers: dict[str, str]) -> None:
|
|
79
|
+
"""Apply Claude Code spoofing headers."""
|
|
80
|
+
# Build beta features header
|
|
81
|
+
beta_features = [
|
|
82
|
+
ANTHROPIC_CONFIG.BETA_OAUTH,
|
|
83
|
+
ANTHROPIC_CONFIG.BETA_CLAUDE_CODE, # Critical for spoofing
|
|
84
|
+
ANTHROPIC_CONFIG.BETA_INTERLEAVED_THINKING,
|
|
85
|
+
# ANTHROPIC_CONFIG.BETA_FINE_GRAINED_STREAMING,
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
# Set the beta header (this is what makes Anthropic think we're Claude Code)
|
|
89
|
+
headers["anthropic-beta"] = ",".join(beta_features)
|
|
90
|
+
|
|
91
|
+
# Set additional headers that Claude Code uses
|
|
92
|
+
headers["anthropic-version"] = "2023-06-01"
|
|
93
|
+
headers.setdefault("accept", "application/json")
|
|
94
|
+
|
|
95
|
+
# Add Claude Code specific headers
|
|
96
|
+
headers["user-agent"] = "Claude-Code/1.0"
|
|
97
|
+
headers["x-client-id"] = ANTHROPIC_CONFIG.OAUTH_CLIENT_ID
|
|
98
|
+
|
|
99
|
+
# Ensure content-type is set for POST requests
|
|
100
|
+
headers.setdefault("content-type", "application/json")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class GlobalAnthropicInterceptor:
|
|
104
|
+
"""Global interceptor for automatic Anthropic request modification."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, storage: SecureAuthStorage):
|
|
107
|
+
"""
|
|
108
|
+
Initialize global interceptor.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
storage: Secure storage for credentials.
|
|
112
|
+
"""
|
|
113
|
+
self.interceptor = AnthropicRequestInterceptor(storage)
|
|
114
|
+
self.original_client_class = httpx.AsyncClient
|
|
115
|
+
|
|
116
|
+
def install(self) -> None:
|
|
117
|
+
"""Install global request interception."""
|
|
118
|
+
interceptor = self.interceptor
|
|
119
|
+
|
|
120
|
+
class InterceptedAsyncClient(httpx.AsyncClient):
|
|
121
|
+
"""Custom AsyncClient that intercepts Anthropic requests."""
|
|
122
|
+
|
|
123
|
+
async def request(self, method: str, url: URL | str, **kwargs: Any) -> httpx.Response:
|
|
124
|
+
"""Override request method to intercept Anthropic API calls."""
|
|
125
|
+
# Convert URL to string if needed
|
|
126
|
+
url_str = str(url)
|
|
127
|
+
|
|
128
|
+
# Check if this is an Anthropic API request
|
|
129
|
+
if "anthropic.com" in url_str or "claude.ai" in url_str:
|
|
130
|
+
# Intercept and modify request
|
|
131
|
+
kwargs = await interceptor.intercept_request(url_str, **kwargs)
|
|
132
|
+
|
|
133
|
+
# Call original request method
|
|
134
|
+
return await super().request(method, url, **kwargs)
|
|
135
|
+
|
|
136
|
+
# Replace httpx.AsyncClient globally
|
|
137
|
+
httpx.AsyncClient = InterceptedAsyncClient # type: ignore
|
|
138
|
+
|
|
139
|
+
def uninstall(self) -> None:
|
|
140
|
+
"""Uninstall global request interception."""
|
|
141
|
+
httpx.AsyncClient = self.original_client_class # type: ignore
|
vibecore/auth/manager.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Main authentication manager for Anthropic Pro/Max."""
|
|
2
|
+
|
|
3
|
+
from vibecore.auth.config import ANTHROPIC_CONFIG
|
|
4
|
+
from vibecore.auth.interceptor import GlobalAnthropicInterceptor
|
|
5
|
+
from vibecore.auth.models import ApiKeyCredentials
|
|
6
|
+
from vibecore.auth.oauth_flow import AnthropicOAuthFlow
|
|
7
|
+
from vibecore.auth.storage import SecureAuthStorage
|
|
8
|
+
from vibecore.auth.token_manager import TokenRefreshManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AnthropicAuthManager:
|
|
12
|
+
"""Main manager for Anthropic authentication."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, app_name: str = "vibecore"):
|
|
15
|
+
"""
|
|
16
|
+
Initialize authentication manager.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
app_name: Application name for storage.
|
|
20
|
+
"""
|
|
21
|
+
self.storage = SecureAuthStorage(app_name)
|
|
22
|
+
self.oauth_flow = AnthropicOAuthFlow()
|
|
23
|
+
self.token_manager = TokenRefreshManager(self.storage)
|
|
24
|
+
self.interceptor = GlobalAnthropicInterceptor(self.storage)
|
|
25
|
+
|
|
26
|
+
async def authenticate_pro_max(self, mode: str = "max") -> bool:
|
|
27
|
+
"""
|
|
28
|
+
Authenticate with Pro/Max subscription via OAuth.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
mode: "max" for claude.ai, "console" for console.anthropic.com.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
True if authentication successful.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
# Step 1: Initiate OAuth flow
|
|
38
|
+
auth_request = await self.oauth_flow.initiate(mode)
|
|
39
|
+
|
|
40
|
+
# Step 2: Open browser for user authorization
|
|
41
|
+
print("\n🌐 Opening browser for authentication...")
|
|
42
|
+
print(f" If browser doesn't open, visit: {auth_request.url}\n")
|
|
43
|
+
self.oauth_flow.open_browser(auth_request.url)
|
|
44
|
+
|
|
45
|
+
# Step 3: Wait for user to provide authorization code
|
|
46
|
+
print("After authorizing, you'll be redirected to a page showing an authorization code.")
|
|
47
|
+
print("Copy the entire code (including the part after #) and paste it below.\n")
|
|
48
|
+
auth_code = input("Authorization code: ").strip()
|
|
49
|
+
|
|
50
|
+
if not auth_code:
|
|
51
|
+
print("❌ No authorization code provided")
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
# Step 4: Exchange code for tokens
|
|
55
|
+
print("🔄 Exchanging authorization code for tokens...")
|
|
56
|
+
credentials = await self.oauth_flow.exchange(auth_code)
|
|
57
|
+
|
|
58
|
+
# Step 5: Save credentials securely
|
|
59
|
+
await self.storage.save("anthropic", credentials)
|
|
60
|
+
|
|
61
|
+
print("✅ Authentication successful! You're now using Claude Pro/Max.\n")
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
except Exception as e:
|
|
65
|
+
print(f"❌ Authentication failed: {e}")
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
async def authenticate_with_api_key(self, api_key: str) -> bool:
|
|
69
|
+
"""
|
|
70
|
+
Authenticate with API key.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
api_key: Anthropic API key.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
True if authentication successful.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
# Validate API key format
|
|
80
|
+
if not api_key.startswith("sk-ant-"):
|
|
81
|
+
print("❌ Invalid API key format. Anthropic keys start with 'sk-ant-'")
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
credentials = ApiKeyCredentials(type="api", key=api_key)
|
|
85
|
+
await self.storage.save("anthropic", credentials)
|
|
86
|
+
|
|
87
|
+
print("✅ API key saved successfully!")
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
except Exception as e:
|
|
91
|
+
print(f"❌ Failed to save API key: {e}")
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
async def is_authenticated(self) -> bool:
|
|
95
|
+
"""Check if user is authenticated."""
|
|
96
|
+
auth = await self.storage.load("anthropic")
|
|
97
|
+
return auth is not None
|
|
98
|
+
|
|
99
|
+
async def get_auth_type(self) -> str | None:
|
|
100
|
+
"""
|
|
101
|
+
Get current authentication type.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
"oauth" for Pro/Max, "api" for API key, None if not authenticated.
|
|
105
|
+
"""
|
|
106
|
+
auth = await self.storage.load("anthropic")
|
|
107
|
+
if auth:
|
|
108
|
+
return auth.type
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
async def logout(self) -> None:
|
|
112
|
+
"""Remove stored authentication."""
|
|
113
|
+
await self.storage.remove("anthropic")
|
|
114
|
+
print("✅ Logged out successfully")
|
|
115
|
+
|
|
116
|
+
def install_interceptor(self) -> None:
|
|
117
|
+
"""Install global request interceptor for Claude Code spoofing."""
|
|
118
|
+
self.interceptor.install()
|
|
119
|
+
|
|
120
|
+
def uninstall_interceptor(self) -> None:
|
|
121
|
+
"""Uninstall global request interceptor."""
|
|
122
|
+
self.interceptor.uninstall()
|
|
123
|
+
|
|
124
|
+
async def test_connection(self) -> bool:
|
|
125
|
+
"""
|
|
126
|
+
Test the authentication by making a simple API call.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
True if connection successful.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
import httpx
|
|
133
|
+
|
|
134
|
+
# Get auth headers
|
|
135
|
+
auth = await self.storage.load("anthropic")
|
|
136
|
+
if not auth:
|
|
137
|
+
print("❌ Not authenticated")
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
headers = {}
|
|
141
|
+
if auth.type == "oauth": # OAuth
|
|
142
|
+
token = await self.token_manager.get_valid_token()
|
|
143
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
144
|
+
else: # API key
|
|
145
|
+
headers["x-api-key"] = auth.key # type: ignore
|
|
146
|
+
|
|
147
|
+
# Add Claude Code headers
|
|
148
|
+
headers["anthropic-beta"] = ",".join(
|
|
149
|
+
[
|
|
150
|
+
ANTHROPIC_CONFIG.BETA_OAUTH,
|
|
151
|
+
ANTHROPIC_CONFIG.BETA_CLAUDE_CODE,
|
|
152
|
+
]
|
|
153
|
+
)
|
|
154
|
+
headers["anthropic-version"] = "2023-06-01"
|
|
155
|
+
|
|
156
|
+
# Make test request
|
|
157
|
+
async with httpx.AsyncClient() as client:
|
|
158
|
+
response = await client.get(
|
|
159
|
+
"https://api.anthropic.com/v1/models",
|
|
160
|
+
headers=headers,
|
|
161
|
+
timeout=10.0,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if response.status_code == 200:
|
|
165
|
+
print("✅ Connection test successful!")
|
|
166
|
+
return True
|
|
167
|
+
else:
|
|
168
|
+
print(f"❌ Connection test failed: {response.status_code}")
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
except Exception as e:
|
|
172
|
+
print(f"❌ Connection test error: {e}")
|
|
173
|
+
return False
|
vibecore/auth/models.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Data models for Anthropic authentication."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class OAuthCredentials:
|
|
9
|
+
"""OAuth credentials for Pro/Max users."""
|
|
10
|
+
|
|
11
|
+
type: Literal["oauth"] = "oauth"
|
|
12
|
+
refresh: str = "" # Refresh token (long-lived)
|
|
13
|
+
access: str = "" # Access token (short-lived)
|
|
14
|
+
expires: int = 0 # Unix timestamp in milliseconds
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ApiKeyCredentials:
|
|
19
|
+
"""API key credentials for standard users."""
|
|
20
|
+
|
|
21
|
+
type: Literal["api"] = "api"
|
|
22
|
+
key: str = "" # API key
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Union type for both authentication methods
|
|
26
|
+
AnthropicAuth = OAuthCredentials | ApiKeyCredentials
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PKCEChallenge:
|
|
31
|
+
"""PKCE challenge pair for OAuth flow."""
|
|
32
|
+
|
|
33
|
+
verifier: str
|
|
34
|
+
challenge: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class AuthorizationRequest:
|
|
39
|
+
"""OAuth authorization request details."""
|
|
40
|
+
|
|
41
|
+
url: str
|
|
42
|
+
verifier: str
|
|
43
|
+
state: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class TokenResponse:
|
|
48
|
+
"""OAuth token response from server."""
|
|
49
|
+
|
|
50
|
+
access_token: str
|
|
51
|
+
refresh_token: str
|
|
52
|
+
expires_in: int
|
|
53
|
+
token_type: str
|
|
54
|
+
scope: str = ""
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""OAuth flow implementation for Anthropic Pro/Max authentication."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import webbrowser
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from vibecore.auth.config import ANTHROPIC_CONFIG
|
|
10
|
+
from vibecore.auth.models import AuthorizationRequest, OAuthCredentials, PKCEChallenge
|
|
11
|
+
from vibecore.auth.pkce import PKCEGenerator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AnthropicOAuthFlow:
|
|
15
|
+
"""Handles OAuth flow for Pro/Max authentication."""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
"""Initialize OAuth flow handler."""
|
|
19
|
+
self.pkce_challenge: PKCEChallenge | None = None
|
|
20
|
+
|
|
21
|
+
async def initiate(self, mode: str = "max") -> AuthorizationRequest:
|
|
22
|
+
"""
|
|
23
|
+
Initiate OAuth flow for Pro/Max authentication.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
mode: "max" for claude.ai, "console" for console.anthropic.com.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Authorization request with URL and PKCE verifier.
|
|
30
|
+
"""
|
|
31
|
+
# Generate PKCE challenge
|
|
32
|
+
self.pkce_challenge = PKCEGenerator.generate()
|
|
33
|
+
|
|
34
|
+
# Select appropriate authorization endpoint
|
|
35
|
+
base_url = ANTHROPIC_CONFIG.CLAUDE_AI_AUTHORIZE if mode == "max" else ANTHROPIC_CONFIG.CONSOLE_AUTHORIZE
|
|
36
|
+
|
|
37
|
+
# Build authorization URL with all required parameters
|
|
38
|
+
params = {
|
|
39
|
+
"code": "true",
|
|
40
|
+
"client_id": ANTHROPIC_CONFIG.OAUTH_CLIENT_ID,
|
|
41
|
+
"response_type": ANTHROPIC_CONFIG.OAUTH_RESPONSE_TYPE,
|
|
42
|
+
"redirect_uri": ANTHROPIC_CONFIG.OAUTH_REDIRECT_URI,
|
|
43
|
+
"scope": ANTHROPIC_CONFIG.OAUTH_SCOPES,
|
|
44
|
+
"code_challenge": self.pkce_challenge.challenge,
|
|
45
|
+
"code_challenge_method": ANTHROPIC_CONFIG.OAUTH_CODE_CHALLENGE_METHOD,
|
|
46
|
+
"state": self.pkce_challenge.verifier, # Using verifier as state
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Create full URL
|
|
50
|
+
auth_url = f"{base_url}?{urlencode(params)}"
|
|
51
|
+
|
|
52
|
+
return AuthorizationRequest(
|
|
53
|
+
url=auth_url,
|
|
54
|
+
verifier=self.pkce_challenge.verifier,
|
|
55
|
+
state=self.pkce_challenge.verifier,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
async def exchange(self, auth_code: str) -> OAuthCredentials:
|
|
59
|
+
"""
|
|
60
|
+
Exchange authorization code for tokens.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
auth_code: Format: "code#state" from callback.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
OAuth credentials with access and refresh tokens.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError: If OAuth flow not initiated or invalid code format.
|
|
70
|
+
httpx.HTTPError: If token exchange fails.
|
|
71
|
+
"""
|
|
72
|
+
if not self.pkce_challenge:
|
|
73
|
+
raise ValueError("OAuth flow not initiated")
|
|
74
|
+
|
|
75
|
+
# Parse the authorization code
|
|
76
|
+
parts = auth_code.split("#")
|
|
77
|
+
if len(parts) != 2:
|
|
78
|
+
raise ValueError("Invalid authorization code format. Expected: code#state")
|
|
79
|
+
|
|
80
|
+
code, state = parts
|
|
81
|
+
|
|
82
|
+
# Prepare token exchange request
|
|
83
|
+
request_body = {
|
|
84
|
+
"code": code,
|
|
85
|
+
"state": state,
|
|
86
|
+
"grant_type": "authorization_code",
|
|
87
|
+
"client_id": ANTHROPIC_CONFIG.OAUTH_CLIENT_ID,
|
|
88
|
+
"redirect_uri": ANTHROPIC_CONFIG.OAUTH_REDIRECT_URI,
|
|
89
|
+
"code_verifier": self.pkce_challenge.verifier,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# Exchange code for tokens
|
|
93
|
+
async with httpx.AsyncClient() as client:
|
|
94
|
+
response = await client.post(
|
|
95
|
+
ANTHROPIC_CONFIG.TOKEN_EXCHANGE,
|
|
96
|
+
headers={
|
|
97
|
+
"Content-Type": "application/json",
|
|
98
|
+
"Accept": "application/json",
|
|
99
|
+
},
|
|
100
|
+
json=request_body,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if response.status_code != 200:
|
|
104
|
+
error_text = response.text
|
|
105
|
+
raise httpx.HTTPError(f"Token exchange failed: {response.status_code} - {error_text}")
|
|
106
|
+
|
|
107
|
+
tokens_data = response.json()
|
|
108
|
+
|
|
109
|
+
# Create credentials object
|
|
110
|
+
credentials = OAuthCredentials(
|
|
111
|
+
type="oauth",
|
|
112
|
+
refresh=tokens_data["refresh_token"],
|
|
113
|
+
access=tokens_data["access_token"],
|
|
114
|
+
expires=int(time.time() * 1000) + tokens_data["expires_in"] * 1000,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Clear PKCE challenge
|
|
118
|
+
self.pkce_challenge = None
|
|
119
|
+
|
|
120
|
+
return credentials
|
|
121
|
+
|
|
122
|
+
def open_browser(self, url: str) -> None:
|
|
123
|
+
"""
|
|
124
|
+
Open browser for user authorization.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
url: Authorization URL.
|
|
128
|
+
"""
|
|
129
|
+
webbrowser.open(url)
|
vibecore/auth/pkce.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""PKCE (Proof Key for Code Exchange) implementation for OAuth."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import secrets
|
|
5
|
+
from base64 import urlsafe_b64encode
|
|
6
|
+
|
|
7
|
+
from vibecore.auth.models import PKCEChallenge
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PKCEGenerator:
|
|
11
|
+
"""Generates cryptographically secure PKCE challenge pairs."""
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def generate() -> PKCEChallenge:
|
|
15
|
+
"""
|
|
16
|
+
Generate a PKCE challenge pair following RFC 7636.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
PKCEChallenge with verifier and challenge.
|
|
20
|
+
"""
|
|
21
|
+
# Generate 32 bytes of random data for verifier
|
|
22
|
+
verifier_bytes = secrets.token_bytes(32)
|
|
23
|
+
verifier = urlsafe_b64encode(verifier_bytes).decode("ascii").rstrip("=")
|
|
24
|
+
|
|
25
|
+
# Create SHA256 hash of verifier for challenge
|
|
26
|
+
challenge_bytes = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
27
|
+
challenge = urlsafe_b64encode(challenge_bytes).decode("ascii").rstrip("=")
|
|
28
|
+
|
|
29
|
+
return PKCEChallenge(verifier=verifier, challenge=challenge)
|
vibecore/auth/storage.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Secure storage for authentication credentials."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from vibecore.auth.models import AnthropicAuth, ApiKeyCredentials, OAuthCredentials
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SecureAuthStorage:
|
|
12
|
+
"""Secure storage for authentication credentials."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, app_name: str = "vibecore"):
|
|
15
|
+
"""
|
|
16
|
+
Initialize secure storage.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
app_name: Application name for storage directory.
|
|
20
|
+
"""
|
|
21
|
+
# Store in user's local data directory
|
|
22
|
+
self.storage_path = Path.home() / ".local" / "share" / app_name / "auth.json"
|
|
23
|
+
|
|
24
|
+
async def save(self, provider: str, credentials: AnthropicAuth) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Save credentials securely.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
provider: Provider name (e.g., "anthropic").
|
|
30
|
+
credentials: Authentication credentials.
|
|
31
|
+
"""
|
|
32
|
+
# Ensure directory exists
|
|
33
|
+
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
# Load existing data
|
|
36
|
+
data = await self._load_all()
|
|
37
|
+
|
|
38
|
+
# Convert credentials to dict
|
|
39
|
+
if isinstance(credentials, OAuthCredentials):
|
|
40
|
+
cred_dict = {
|
|
41
|
+
"type": "oauth",
|
|
42
|
+
"refresh": credentials.refresh,
|
|
43
|
+
"access": credentials.access,
|
|
44
|
+
"expires": credentials.expires,
|
|
45
|
+
}
|
|
46
|
+
elif isinstance(credentials, ApiKeyCredentials):
|
|
47
|
+
cred_dict = {"type": "api", "key": credentials.key}
|
|
48
|
+
else:
|
|
49
|
+
raise ValueError(f"Unknown credential type: {type(credentials)}")
|
|
50
|
+
|
|
51
|
+
# Update credentials
|
|
52
|
+
data[provider] = cred_dict
|
|
53
|
+
|
|
54
|
+
# Write with secure permissions (owner read/write only)
|
|
55
|
+
self.storage_path.write_text(json.dumps(data, indent=2))
|
|
56
|
+
os.chmod(self.storage_path, 0o600)
|
|
57
|
+
|
|
58
|
+
async def load(self, provider: str) -> AnthropicAuth | None:
|
|
59
|
+
"""
|
|
60
|
+
Load credentials for a provider.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
provider: Provider name.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Authentication credentials or None if not found.
|
|
67
|
+
"""
|
|
68
|
+
data = await self._load_all()
|
|
69
|
+
cred_dict = data.get(provider)
|
|
70
|
+
|
|
71
|
+
if not cred_dict:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# Convert dict back to credentials object
|
|
75
|
+
if cred_dict.get("type") == "oauth":
|
|
76
|
+
return OAuthCredentials(
|
|
77
|
+
type="oauth",
|
|
78
|
+
refresh=cred_dict.get("refresh", ""),
|
|
79
|
+
access=cred_dict.get("access", ""),
|
|
80
|
+
expires=cred_dict.get("expires", 0),
|
|
81
|
+
)
|
|
82
|
+
elif cred_dict.get("type") == "api":
|
|
83
|
+
return ApiKeyCredentials(type="api", key=cred_dict.get("key", ""))
|
|
84
|
+
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
async def remove(self, provider: str) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Remove credentials for a provider.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
provider: Provider name.
|
|
93
|
+
"""
|
|
94
|
+
data = await self._load_all()
|
|
95
|
+
data.pop(provider, None)
|
|
96
|
+
self.storage_path.write_text(json.dumps(data, indent=2))
|
|
97
|
+
os.chmod(self.storage_path, 0o600)
|
|
98
|
+
|
|
99
|
+
async def _load_all(self) -> dict[str, Any]:
|
|
100
|
+
"""Load all stored credentials."""
|
|
101
|
+
if not self.storage_path.exists():
|
|
102
|
+
return {}
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
return json.loads(self.storage_path.read_text())
|
|
106
|
+
except (json.JSONDecodeError, OSError):
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
def exists(self) -> bool:
|
|
110
|
+
"""Check if any credentials are stored."""
|
|
111
|
+
return self.storage_path.exists() and self.storage_path.stat().st_size > 2
|