ccproxy-api 0.1.0__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.
- ccproxy/__init__.py +4 -0
- ccproxy/__main__.py +7 -0
- ccproxy/_version.py +21 -0
- ccproxy/adapters/__init__.py +11 -0
- ccproxy/adapters/base.py +80 -0
- ccproxy/adapters/openai/__init__.py +43 -0
- ccproxy/adapters/openai/adapter.py +915 -0
- ccproxy/adapters/openai/models.py +412 -0
- ccproxy/adapters/openai/streaming.py +449 -0
- ccproxy/api/__init__.py +28 -0
- ccproxy/api/app.py +225 -0
- ccproxy/api/dependencies.py +140 -0
- ccproxy/api/middleware/__init__.py +11 -0
- ccproxy/api/middleware/auth.py +0 -0
- ccproxy/api/middleware/cors.py +55 -0
- ccproxy/api/middleware/errors.py +703 -0
- ccproxy/api/middleware/headers.py +51 -0
- ccproxy/api/middleware/logging.py +175 -0
- ccproxy/api/middleware/request_id.py +69 -0
- ccproxy/api/middleware/server_header.py +62 -0
- ccproxy/api/responses.py +84 -0
- ccproxy/api/routes/__init__.py +16 -0
- ccproxy/api/routes/claude.py +181 -0
- ccproxy/api/routes/health.py +489 -0
- ccproxy/api/routes/metrics.py +1033 -0
- ccproxy/api/routes/proxy.py +238 -0
- ccproxy/auth/__init__.py +75 -0
- ccproxy/auth/bearer.py +68 -0
- ccproxy/auth/credentials_adapter.py +93 -0
- ccproxy/auth/dependencies.py +229 -0
- ccproxy/auth/exceptions.py +79 -0
- ccproxy/auth/manager.py +102 -0
- ccproxy/auth/models.py +118 -0
- ccproxy/auth/oauth/__init__.py +26 -0
- ccproxy/auth/oauth/models.py +49 -0
- ccproxy/auth/oauth/routes.py +396 -0
- ccproxy/auth/oauth/storage.py +0 -0
- ccproxy/auth/storage/__init__.py +12 -0
- ccproxy/auth/storage/base.py +57 -0
- ccproxy/auth/storage/json_file.py +159 -0
- ccproxy/auth/storage/keyring.py +192 -0
- ccproxy/claude_sdk/__init__.py +20 -0
- ccproxy/claude_sdk/client.py +169 -0
- ccproxy/claude_sdk/converter.py +331 -0
- ccproxy/claude_sdk/options.py +120 -0
- ccproxy/cli/__init__.py +14 -0
- ccproxy/cli/commands/__init__.py +8 -0
- ccproxy/cli/commands/auth.py +553 -0
- ccproxy/cli/commands/config/__init__.py +14 -0
- ccproxy/cli/commands/config/commands.py +766 -0
- ccproxy/cli/commands/config/schema_commands.py +119 -0
- ccproxy/cli/commands/serve.py +630 -0
- ccproxy/cli/docker/__init__.py +34 -0
- ccproxy/cli/docker/adapter_factory.py +157 -0
- ccproxy/cli/docker/params.py +278 -0
- ccproxy/cli/helpers.py +144 -0
- ccproxy/cli/main.py +193 -0
- ccproxy/cli/options/__init__.py +14 -0
- ccproxy/cli/options/claude_options.py +216 -0
- ccproxy/cli/options/core_options.py +40 -0
- ccproxy/cli/options/security_options.py +48 -0
- ccproxy/cli/options/server_options.py +117 -0
- ccproxy/config/__init__.py +40 -0
- ccproxy/config/auth.py +154 -0
- ccproxy/config/claude.py +124 -0
- ccproxy/config/cors.py +79 -0
- ccproxy/config/discovery.py +87 -0
- ccproxy/config/docker_settings.py +265 -0
- ccproxy/config/loader.py +108 -0
- ccproxy/config/observability.py +158 -0
- ccproxy/config/pricing.py +88 -0
- ccproxy/config/reverse_proxy.py +31 -0
- ccproxy/config/scheduler.py +89 -0
- ccproxy/config/security.py +14 -0
- ccproxy/config/server.py +81 -0
- ccproxy/config/settings.py +534 -0
- ccproxy/config/validators.py +231 -0
- ccproxy/core/__init__.py +274 -0
- ccproxy/core/async_utils.py +675 -0
- ccproxy/core/constants.py +97 -0
- ccproxy/core/errors.py +256 -0
- ccproxy/core/http.py +328 -0
- ccproxy/core/http_transformers.py +428 -0
- ccproxy/core/interfaces.py +247 -0
- ccproxy/core/logging.py +189 -0
- ccproxy/core/middleware.py +114 -0
- ccproxy/core/proxy.py +143 -0
- ccproxy/core/system.py +38 -0
- ccproxy/core/transformers.py +259 -0
- ccproxy/core/types.py +129 -0
- ccproxy/core/validators.py +288 -0
- ccproxy/docker/__init__.py +67 -0
- ccproxy/docker/adapter.py +588 -0
- ccproxy/docker/docker_path.py +207 -0
- ccproxy/docker/middleware.py +103 -0
- ccproxy/docker/models.py +228 -0
- ccproxy/docker/protocol.py +192 -0
- ccproxy/docker/stream_process.py +264 -0
- ccproxy/docker/validators.py +173 -0
- ccproxy/models/__init__.py +123 -0
- ccproxy/models/errors.py +42 -0
- ccproxy/models/messages.py +243 -0
- ccproxy/models/requests.py +85 -0
- ccproxy/models/responses.py +227 -0
- ccproxy/models/types.py +102 -0
- ccproxy/observability/__init__.py +51 -0
- ccproxy/observability/access_logger.py +400 -0
- ccproxy/observability/context.py +447 -0
- ccproxy/observability/metrics.py +539 -0
- ccproxy/observability/pushgateway.py +366 -0
- ccproxy/observability/sse_events.py +303 -0
- ccproxy/observability/stats_printer.py +755 -0
- ccproxy/observability/storage/__init__.py +1 -0
- ccproxy/observability/storage/duckdb_simple.py +665 -0
- ccproxy/observability/storage/models.py +55 -0
- ccproxy/pricing/__init__.py +19 -0
- ccproxy/pricing/cache.py +212 -0
- ccproxy/pricing/loader.py +267 -0
- ccproxy/pricing/models.py +106 -0
- ccproxy/pricing/updater.py +309 -0
- ccproxy/scheduler/__init__.py +39 -0
- ccproxy/scheduler/core.py +335 -0
- ccproxy/scheduler/exceptions.py +34 -0
- ccproxy/scheduler/manager.py +186 -0
- ccproxy/scheduler/registry.py +150 -0
- ccproxy/scheduler/tasks.py +484 -0
- ccproxy/services/__init__.py +10 -0
- ccproxy/services/claude_sdk_service.py +614 -0
- ccproxy/services/credentials/__init__.py +55 -0
- ccproxy/services/credentials/config.py +105 -0
- ccproxy/services/credentials/manager.py +562 -0
- ccproxy/services/credentials/oauth_client.py +482 -0
- ccproxy/services/proxy_service.py +1536 -0
- ccproxy/static/.keep +0 -0
- ccproxy/testing/__init__.py +34 -0
- ccproxy/testing/config.py +148 -0
- ccproxy/testing/content_generation.py +197 -0
- ccproxy/testing/mock_responses.py +262 -0
- ccproxy/testing/response_handlers.py +161 -0
- ccproxy/testing/scenarios.py +241 -0
- ccproxy/utils/__init__.py +6 -0
- ccproxy/utils/cost_calculator.py +210 -0
- ccproxy/utils/streaming_metrics.py +199 -0
- ccproxy_api-0.1.0.dist-info/METADATA +253 -0
- ccproxy_api-0.1.0.dist-info/RECORD +148 -0
- ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
- ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
- ccproxy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""OAuth authentication routes for Anthropic OAuth login."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
9
|
+
from fastapi.responses import HTMLResponse
|
|
10
|
+
from structlog import get_logger
|
|
11
|
+
|
|
12
|
+
from ccproxy.auth.models import (
|
|
13
|
+
ClaudeCredentials,
|
|
14
|
+
OAuthToken,
|
|
15
|
+
)
|
|
16
|
+
from ccproxy.auth.storage import JsonFileTokenStorage as JsonFileStorage
|
|
17
|
+
|
|
18
|
+
# Import CredentialsManager locally to avoid circular import
|
|
19
|
+
from ccproxy.services.credentials.config import OAuthConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
router = APIRouter(tags=["oauth"])
|
|
25
|
+
|
|
26
|
+
# Store for pending OAuth flows
|
|
27
|
+
_pending_flows: dict[str, dict[str, Any]] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_oauth_flow(
|
|
31
|
+
state: str, code_verifier: str, custom_paths: list[Path] | None = None
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Register a pending OAuth flow."""
|
|
34
|
+
_pending_flows[state] = {
|
|
35
|
+
"code_verifier": code_verifier,
|
|
36
|
+
"custom_paths": custom_paths,
|
|
37
|
+
"completed": False,
|
|
38
|
+
"success": False,
|
|
39
|
+
"error": None,
|
|
40
|
+
}
|
|
41
|
+
logger.debug("Registered OAuth flow", state=state, operation="register_oauth_flow")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_oauth_flow_result(state: str) -> dict[str, Any] | None:
|
|
45
|
+
"""Get and remove OAuth flow result."""
|
|
46
|
+
return _pending_flows.pop(state, None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.get("/callback")
|
|
50
|
+
async def oauth_callback(
|
|
51
|
+
request: Request,
|
|
52
|
+
code: str | None = Query(None, description="Authorization code"),
|
|
53
|
+
state: str | None = Query(None, description="State parameter"),
|
|
54
|
+
error: str | None = Query(None, description="OAuth error"),
|
|
55
|
+
error_description: str | None = Query(None, description="OAuth error description"),
|
|
56
|
+
) -> HTMLResponse:
|
|
57
|
+
"""Handle OAuth callback from Claude authentication.
|
|
58
|
+
|
|
59
|
+
This endpoint receives the authorization code from Claude's OAuth flow
|
|
60
|
+
and exchanges it for access tokens.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
if error:
|
|
64
|
+
error_msg = error_description or error or "OAuth authentication failed"
|
|
65
|
+
logger.error(
|
|
66
|
+
"OAuth callback error",
|
|
67
|
+
error_type="oauth_error",
|
|
68
|
+
error_message=error_msg,
|
|
69
|
+
oauth_error=error,
|
|
70
|
+
oauth_error_description=error_description,
|
|
71
|
+
state=state,
|
|
72
|
+
operation="oauth_callback",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Update pending flow if state is provided
|
|
76
|
+
if state and state in _pending_flows:
|
|
77
|
+
_pending_flows[state].update(
|
|
78
|
+
{
|
|
79
|
+
"completed": True,
|
|
80
|
+
"success": False,
|
|
81
|
+
"error": error_msg,
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return HTMLResponse(
|
|
86
|
+
content=f"""
|
|
87
|
+
<html>
|
|
88
|
+
<head><title>Login Failed</title></head>
|
|
89
|
+
<body>
|
|
90
|
+
<h1>Login Failed</h1>
|
|
91
|
+
<p>Error: {error_msg}</p>
|
|
92
|
+
<p>You can close this window and try again.</p>
|
|
93
|
+
</body>
|
|
94
|
+
</html>
|
|
95
|
+
""",
|
|
96
|
+
status_code=400,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if not code:
|
|
100
|
+
error_msg = "No authorization code received"
|
|
101
|
+
logger.error(
|
|
102
|
+
"OAuth callback missing authorization code",
|
|
103
|
+
error_type="missing_code",
|
|
104
|
+
error_message=error_msg,
|
|
105
|
+
state=state,
|
|
106
|
+
operation="oauth_callback",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if state and state in _pending_flows:
|
|
110
|
+
_pending_flows[state].update(
|
|
111
|
+
{
|
|
112
|
+
"completed": True,
|
|
113
|
+
"success": False,
|
|
114
|
+
"error": error_msg,
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return HTMLResponse(
|
|
119
|
+
content=f"""
|
|
120
|
+
<html>
|
|
121
|
+
<head><title>Login Failed</title></head>
|
|
122
|
+
<body>
|
|
123
|
+
<h1>Login Failed</h1>
|
|
124
|
+
<p>Error: {error_msg}</p>
|
|
125
|
+
<p>You can close this window and try again.</p>
|
|
126
|
+
</body>
|
|
127
|
+
</html>
|
|
128
|
+
""",
|
|
129
|
+
status_code=400,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if not state:
|
|
133
|
+
error_msg = "Missing state parameter"
|
|
134
|
+
logger.error(
|
|
135
|
+
"OAuth callback missing state parameter",
|
|
136
|
+
error_type="missing_state",
|
|
137
|
+
error_message=error_msg,
|
|
138
|
+
operation="oauth_callback",
|
|
139
|
+
)
|
|
140
|
+
return HTMLResponse(
|
|
141
|
+
content=f"""
|
|
142
|
+
<html>
|
|
143
|
+
<head><title>Login Failed</title></head>
|
|
144
|
+
<body>
|
|
145
|
+
<h1>Login Failed</h1>
|
|
146
|
+
<p>Error: {error_msg}</p>
|
|
147
|
+
<p>You can close this window and try again.</p>
|
|
148
|
+
</body>
|
|
149
|
+
</html>
|
|
150
|
+
""",
|
|
151
|
+
status_code=400,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Check if this is a valid pending flow
|
|
155
|
+
if state not in _pending_flows:
|
|
156
|
+
error_msg = "Invalid or expired state parameter"
|
|
157
|
+
logger.error(
|
|
158
|
+
"OAuth callback with invalid state",
|
|
159
|
+
error_type="invalid_state",
|
|
160
|
+
error_message="Invalid or expired state parameter",
|
|
161
|
+
state=state,
|
|
162
|
+
operation="oauth_callback",
|
|
163
|
+
)
|
|
164
|
+
return HTMLResponse(
|
|
165
|
+
content=f"""
|
|
166
|
+
<html>
|
|
167
|
+
<head><title>Login Failed</title></head>
|
|
168
|
+
<body>
|
|
169
|
+
<h1>Login Failed</h1>
|
|
170
|
+
<p>Error: {error_msg}</p>
|
|
171
|
+
<p>You can close this window and try again.</p>
|
|
172
|
+
</body>
|
|
173
|
+
</html>
|
|
174
|
+
""",
|
|
175
|
+
status_code=400,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Get flow details
|
|
179
|
+
flow = _pending_flows[state]
|
|
180
|
+
code_verifier = flow["code_verifier"]
|
|
181
|
+
custom_paths = flow["custom_paths"]
|
|
182
|
+
|
|
183
|
+
# Exchange authorization code for tokens
|
|
184
|
+
success = await _exchange_code_for_tokens(code, code_verifier, custom_paths)
|
|
185
|
+
|
|
186
|
+
# Update flow result
|
|
187
|
+
_pending_flows[state].update(
|
|
188
|
+
{
|
|
189
|
+
"completed": True,
|
|
190
|
+
"success": success,
|
|
191
|
+
"error": None if success else "Token exchange failed",
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if success:
|
|
196
|
+
logger.info(
|
|
197
|
+
"OAuth login successful", state=state, operation="oauth_callback"
|
|
198
|
+
)
|
|
199
|
+
return HTMLResponse(
|
|
200
|
+
content="""
|
|
201
|
+
<html>
|
|
202
|
+
<head><title>Login Successful</title></head>
|
|
203
|
+
<body>
|
|
204
|
+
<h1>Login Successful!</h1>
|
|
205
|
+
<p>You have successfully logged in to Claude.</p>
|
|
206
|
+
<p>You can close this window and return to the CLI.</p>
|
|
207
|
+
<script>
|
|
208
|
+
setTimeout(() => {
|
|
209
|
+
window.close();
|
|
210
|
+
}, 3000);
|
|
211
|
+
</script>
|
|
212
|
+
</body>
|
|
213
|
+
</html>
|
|
214
|
+
""",
|
|
215
|
+
status_code=200,
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
error_msg = "Failed to exchange authorization code for tokens"
|
|
219
|
+
logger.error(
|
|
220
|
+
"OAuth token exchange failed",
|
|
221
|
+
error_type="token_exchange_failed",
|
|
222
|
+
error_message=error_msg,
|
|
223
|
+
state=state,
|
|
224
|
+
operation="oauth_callback",
|
|
225
|
+
)
|
|
226
|
+
return HTMLResponse(
|
|
227
|
+
content=f"""
|
|
228
|
+
<html>
|
|
229
|
+
<head><title>Login Failed</title></head>
|
|
230
|
+
<body>
|
|
231
|
+
<h1>Login Failed</h1>
|
|
232
|
+
<p>Error: {error_msg}</p>
|
|
233
|
+
<p>You can close this window and try again.</p>
|
|
234
|
+
</body>
|
|
235
|
+
</html>
|
|
236
|
+
""",
|
|
237
|
+
status_code=500,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.error(
|
|
242
|
+
"Unexpected error in OAuth callback",
|
|
243
|
+
error_type="unexpected_error",
|
|
244
|
+
error_message=str(e),
|
|
245
|
+
state=state,
|
|
246
|
+
operation="oauth_callback",
|
|
247
|
+
exc_info=True,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if state and state in _pending_flows:
|
|
251
|
+
_pending_flows[state].update(
|
|
252
|
+
{
|
|
253
|
+
"completed": True,
|
|
254
|
+
"success": False,
|
|
255
|
+
"error": str(e),
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return HTMLResponse(
|
|
260
|
+
content=f"""
|
|
261
|
+
<html>
|
|
262
|
+
<head><title>Login Error</title></head>
|
|
263
|
+
<body>
|
|
264
|
+
<h1>Login Error</h1>
|
|
265
|
+
<p>An unexpected error occurred: {str(e)}</p>
|
|
266
|
+
<p>You can close this window and try again.</p>
|
|
267
|
+
</body>
|
|
268
|
+
</html>
|
|
269
|
+
""",
|
|
270
|
+
status_code=500,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def _exchange_code_for_tokens(
|
|
275
|
+
authorization_code: str, code_verifier: str, custom_paths: list[Path] | None = None
|
|
276
|
+
) -> bool:
|
|
277
|
+
"""Exchange authorization code for access tokens."""
|
|
278
|
+
try:
|
|
279
|
+
from datetime import UTC, datetime
|
|
280
|
+
|
|
281
|
+
import httpx
|
|
282
|
+
|
|
283
|
+
# Create OAuth config with default values
|
|
284
|
+
oauth_config = OAuthConfig()
|
|
285
|
+
|
|
286
|
+
# Exchange authorization code for tokens
|
|
287
|
+
token_data = {
|
|
288
|
+
"grant_type": "authorization_code",
|
|
289
|
+
"code": authorization_code,
|
|
290
|
+
"redirect_uri": oauth_config.redirect_uri,
|
|
291
|
+
"client_id": oauth_config.client_id,
|
|
292
|
+
"code_verifier": code_verifier,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
headers = {
|
|
296
|
+
"Content-Type": "application/json",
|
|
297
|
+
"anthropic-beta": oauth_config.beta_version,
|
|
298
|
+
"User-Agent": oauth_config.user_agent,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async with httpx.AsyncClient() as client:
|
|
302
|
+
response = await client.post(
|
|
303
|
+
oauth_config.token_url,
|
|
304
|
+
headers=headers,
|
|
305
|
+
json=token_data,
|
|
306
|
+
timeout=30.0,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if response.status_code == 200:
|
|
310
|
+
result = response.json()
|
|
311
|
+
|
|
312
|
+
# Calculate expires_at from expires_in
|
|
313
|
+
expires_in = result.get("expires_in")
|
|
314
|
+
expires_at = None
|
|
315
|
+
if expires_in:
|
|
316
|
+
expires_at = int(
|
|
317
|
+
(datetime.now(UTC).timestamp() + expires_in) * 1000
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Create credentials object
|
|
321
|
+
oauth_data = {
|
|
322
|
+
"accessToken": result.get("access_token"),
|
|
323
|
+
"refreshToken": result.get("refresh_token"),
|
|
324
|
+
"expiresAt": expires_at,
|
|
325
|
+
"scopes": result.get("scope", "").split()
|
|
326
|
+
if result.get("scope")
|
|
327
|
+
else oauth_config.scopes,
|
|
328
|
+
"subscriptionType": result.get("subscription_type", "unknown"),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
credentials = ClaudeCredentials(claudeAiOauth=OAuthToken(**oauth_data))
|
|
332
|
+
|
|
333
|
+
# Save credentials using CredentialsManager (lazy import to avoid circular import)
|
|
334
|
+
from ccproxy.services.credentials.manager import CredentialsManager
|
|
335
|
+
|
|
336
|
+
if custom_paths:
|
|
337
|
+
# Use the first custom path for storage
|
|
338
|
+
storage = JsonFileStorage(custom_paths[0])
|
|
339
|
+
manager = CredentialsManager(storage=storage)
|
|
340
|
+
else:
|
|
341
|
+
manager = CredentialsManager()
|
|
342
|
+
|
|
343
|
+
if await manager.save(credentials):
|
|
344
|
+
logger.info(
|
|
345
|
+
"Successfully saved OAuth credentials",
|
|
346
|
+
subscription_type=oauth_data["subscriptionType"],
|
|
347
|
+
scopes=oauth_data["scopes"],
|
|
348
|
+
operation="exchange_code_for_tokens",
|
|
349
|
+
)
|
|
350
|
+
return True
|
|
351
|
+
else:
|
|
352
|
+
logger.error(
|
|
353
|
+
"Failed to save OAuth credentials",
|
|
354
|
+
error_type="save_credentials_failed",
|
|
355
|
+
operation="exchange_code_for_tokens",
|
|
356
|
+
)
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
else:
|
|
360
|
+
# Use compact logging for the error message
|
|
361
|
+
import os
|
|
362
|
+
|
|
363
|
+
verbose_api = (
|
|
364
|
+
os.environ.get("CCPROXY_VERBOSE_API", "false").lower() == "true"
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
if verbose_api:
|
|
368
|
+
error_detail = response.text
|
|
369
|
+
else:
|
|
370
|
+
response_text = response.text
|
|
371
|
+
if len(response_text) > 200:
|
|
372
|
+
error_detail = f"{response_text[:100]}...{response_text[-50:]}"
|
|
373
|
+
elif len(response_text) > 100:
|
|
374
|
+
error_detail = f"{response_text[:100]}..."
|
|
375
|
+
else:
|
|
376
|
+
error_detail = response_text
|
|
377
|
+
|
|
378
|
+
logger.error(
|
|
379
|
+
"Token exchange failed",
|
|
380
|
+
error_type="token_exchange_failed",
|
|
381
|
+
status_code=response.status_code,
|
|
382
|
+
error_detail=error_detail,
|
|
383
|
+
verbose_api_enabled=verbose_api,
|
|
384
|
+
operation="exchange_code_for_tokens",
|
|
385
|
+
)
|
|
386
|
+
return False
|
|
387
|
+
|
|
388
|
+
except Exception as e:
|
|
389
|
+
logger.error(
|
|
390
|
+
"Error during token exchange",
|
|
391
|
+
error_type="token_exchange_exception",
|
|
392
|
+
error_message=str(e),
|
|
393
|
+
operation="exchange_code_for_tokens",
|
|
394
|
+
exc_info=True,
|
|
395
|
+
)
|
|
396
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Token storage implementations for authentication."""
|
|
2
|
+
|
|
3
|
+
from ccproxy.auth.storage.base import TokenStorage
|
|
4
|
+
from ccproxy.auth.storage.json_file import JsonFileTokenStorage
|
|
5
|
+
from ccproxy.auth.storage.keyring import KeyringTokenStorage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"TokenStorage",
|
|
10
|
+
"JsonFileTokenStorage",
|
|
11
|
+
"KeyringTokenStorage",
|
|
12
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Abstract base class for token storage."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from ccproxy.auth.models import ClaudeCredentials
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TokenStorage(ABC):
|
|
9
|
+
"""Abstract interface for token storage operations."""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def load(self) -> ClaudeCredentials | None:
|
|
13
|
+
"""Load credentials from storage.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Parsed credentials if found and valid, None otherwise
|
|
17
|
+
"""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def save(self, credentials: ClaudeCredentials) -> bool:
|
|
22
|
+
"""Save credentials to storage.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
credentials: Credentials to save
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
True if saved successfully, False otherwise
|
|
29
|
+
"""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def exists(self) -> bool:
|
|
34
|
+
"""Check if credentials exist in storage.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
True if credentials exist, False otherwise
|
|
38
|
+
"""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def delete(self) -> bool:
|
|
43
|
+
"""Delete credentials from storage.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True if deleted successfully, False otherwise
|
|
47
|
+
"""
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def get_location(self) -> str:
|
|
52
|
+
"""Get the storage location description.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Human-readable description of where credentials are stored
|
|
56
|
+
"""
|
|
57
|
+
pass
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""JSON file storage implementation for token storage."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from structlog import get_logger
|
|
9
|
+
|
|
10
|
+
from ccproxy.auth.exceptions import (
|
|
11
|
+
CredentialsInvalidError,
|
|
12
|
+
CredentialsStorageError,
|
|
13
|
+
)
|
|
14
|
+
from ccproxy.auth.models import ClaudeCredentials
|
|
15
|
+
from ccproxy.auth.storage.base import TokenStorage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JsonFileTokenStorage(TokenStorage):
|
|
22
|
+
"""JSON file storage implementation for Claude credentials with keyring fallback."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, file_path: Path):
|
|
25
|
+
"""Initialize JSON file storage.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
file_path: Path to the JSON credentials file
|
|
29
|
+
"""
|
|
30
|
+
self.file_path = file_path
|
|
31
|
+
|
|
32
|
+
async def load(self) -> ClaudeCredentials | None:
|
|
33
|
+
"""Load credentials from JSON file .
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Parsed credentials if found and valid, None otherwise
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
CredentialsInvalidError: If the JSON file is invalid
|
|
40
|
+
CredentialsStorageError: If there's an error reading the file
|
|
41
|
+
"""
|
|
42
|
+
if not await self.exists():
|
|
43
|
+
logger.debug("credentials_file_not_found", path=str(self.file_path))
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
logger.debug(
|
|
48
|
+
"credentials_load_start", source="file", path=str(self.file_path)
|
|
49
|
+
)
|
|
50
|
+
with self.file_path.open() as f:
|
|
51
|
+
data = json.load(f)
|
|
52
|
+
|
|
53
|
+
credentials = ClaudeCredentials.model_validate(data)
|
|
54
|
+
logger.debug("credentials_load_completed", source="file")
|
|
55
|
+
|
|
56
|
+
return credentials
|
|
57
|
+
|
|
58
|
+
except json.JSONDecodeError as e:
|
|
59
|
+
raise CredentialsInvalidError(
|
|
60
|
+
f"Failed to parse credentials file {self.file_path}: {e}"
|
|
61
|
+
) from e
|
|
62
|
+
except Exception as e:
|
|
63
|
+
raise CredentialsStorageError(
|
|
64
|
+
f"Error loading credentials from {self.file_path}: {e}"
|
|
65
|
+
) from e
|
|
66
|
+
|
|
67
|
+
async def save(self, credentials: ClaudeCredentials) -> bool:
|
|
68
|
+
"""Save credentials to both keyring and JSON file.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
credentials: Credentials to save
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
True if saved successfully, False otherwise
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
CredentialsStorageError: If there's an error writing the file
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
# Convert to dict with proper aliases
|
|
81
|
+
data = credentials.model_dump(by_alias=True, mode="json")
|
|
82
|
+
|
|
83
|
+
# Always save to file as well
|
|
84
|
+
# Ensure parent directory exists
|
|
85
|
+
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
|
|
87
|
+
# Use atomic write: write to temp file then rename
|
|
88
|
+
temp_path = self.file_path.with_suffix(".tmp")
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
with temp_path.open("w") as f:
|
|
92
|
+
json.dump(data, f, indent=2)
|
|
93
|
+
|
|
94
|
+
# Set appropriate file permissions (read/write for owner only)
|
|
95
|
+
temp_path.chmod(0o600)
|
|
96
|
+
|
|
97
|
+
# Atomically replace the original file
|
|
98
|
+
Path.replace(temp_path, self.file_path)
|
|
99
|
+
|
|
100
|
+
logger.debug(
|
|
101
|
+
"credentials_save_completed",
|
|
102
|
+
source="file",
|
|
103
|
+
path=str(self.file_path),
|
|
104
|
+
)
|
|
105
|
+
return True
|
|
106
|
+
except Exception as e:
|
|
107
|
+
raise
|
|
108
|
+
finally:
|
|
109
|
+
# Clean up temp file if it exists
|
|
110
|
+
if temp_path.exists():
|
|
111
|
+
with contextlib.suppress(Exception):
|
|
112
|
+
temp_path.unlink()
|
|
113
|
+
|
|
114
|
+
except Exception as e:
|
|
115
|
+
raise CredentialsStorageError(f"Error saving credentials: {e}") from e
|
|
116
|
+
|
|
117
|
+
async def exists(self) -> bool:
|
|
118
|
+
"""Check if credentials file exists.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
True if file exists, False otherwise
|
|
122
|
+
"""
|
|
123
|
+
return self.file_path.exists() and self.file_path.is_file()
|
|
124
|
+
|
|
125
|
+
async def delete(self) -> bool:
|
|
126
|
+
"""Delete credentials from both keyring and file.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
True if deleted successfully, False otherwise
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
CredentialsStorageError: If there's an error deleting the file
|
|
133
|
+
"""
|
|
134
|
+
deleted = False
|
|
135
|
+
|
|
136
|
+
# Delete from file
|
|
137
|
+
try:
|
|
138
|
+
if await self.exists():
|
|
139
|
+
self.file_path.unlink()
|
|
140
|
+
logger.debug(
|
|
141
|
+
"credentials_delete_completed",
|
|
142
|
+
source="file",
|
|
143
|
+
path=str(self.file_path),
|
|
144
|
+
)
|
|
145
|
+
deleted = True
|
|
146
|
+
except Exception as e:
|
|
147
|
+
if not deleted: # Only raise if we failed to delete from both
|
|
148
|
+
raise CredentialsStorageError(f"Error deleting credentials: {e}") from e
|
|
149
|
+
logger.debug("credentials_delete_partial", source="file", error=str(e))
|
|
150
|
+
|
|
151
|
+
return deleted
|
|
152
|
+
|
|
153
|
+
def get_location(self) -> str:
|
|
154
|
+
"""Get the storage location description.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Path to the JSON file with keyring info if available
|
|
158
|
+
"""
|
|
159
|
+
return str(self.file_path)
|