xenfra 0.4.3__py3-none-any.whl → 0.4.4__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.
- xenfra/commands/__init__.py +3 -3
- xenfra/commands/auth.py +144 -144
- xenfra/commands/auth_device.py +164 -164
- xenfra/commands/deployments.py +1133 -973
- xenfra/commands/intelligence.py +503 -412
- xenfra/commands/projects.py +204 -204
- xenfra/commands/security_cmd.py +233 -233
- xenfra/main.py +76 -75
- xenfra/utils/__init__.py +3 -3
- xenfra/utils/auth.py +374 -374
- xenfra/utils/codebase.py +169 -169
- xenfra/utils/config.py +459 -436
- xenfra/utils/errors.py +116 -116
- xenfra/utils/file_sync.py +286 -286
- xenfra/utils/security.py +336 -336
- xenfra/utils/validation.py +234 -234
- xenfra-0.4.4.dist-info/METADATA +113 -0
- xenfra-0.4.4.dist-info/RECORD +21 -0
- xenfra-0.4.3.dist-info/METADATA +0 -118
- xenfra-0.4.3.dist-info/RECORD +0 -21
- {xenfra-0.4.3.dist-info → xenfra-0.4.4.dist-info}/WHEEL +0 -0
- {xenfra-0.4.3.dist-info → xenfra-0.4.4.dist-info}/entry_points.txt +0 -0
xenfra/utils/auth.py
CHANGED
|
@@ -1,374 +1,374 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Authentication utilities for Xenfra CLI.
|
|
3
|
-
Handles OAuth2 PKCE flow and token management.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import json
|
|
7
|
-
import os
|
|
8
|
-
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from urllib.parse import parse_qs, urlparse
|
|
11
|
-
|
|
12
|
-
import httpx
|
|
13
|
-
import keyring
|
|
14
|
-
from rich.console import Console
|
|
15
|
-
from tenacity import (
|
|
16
|
-
retry,
|
|
17
|
-
retry_if_exception_type,
|
|
18
|
-
stop_after_attempt,
|
|
19
|
-
wait_exponential,
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
from .security import validate_and_get_api_url
|
|
23
|
-
|
|
24
|
-
console = Console()
|
|
25
|
-
|
|
26
|
-
# Get validated API URL (includes all security checks)
|
|
27
|
-
API_BASE_URL = validate_and_get_api_url()
|
|
28
|
-
SERVICE_ID = "xenfra"
|
|
29
|
-
|
|
30
|
-
# Fallback file-based token storage (for Windows if keyring fails)
|
|
31
|
-
TOKEN_FILE = Path.home() / ".xenfra" / "tokens.json"
|
|
32
|
-
|
|
33
|
-
# CLI OAuth2 Configuration
|
|
34
|
-
CLI_CLIENT_ID = "xenfra-cli"
|
|
35
|
-
CLI_REDIRECT_PATH = "/auth/callback"
|
|
36
|
-
CLI_LOCAL_SERVER_START_PORT = 8001
|
|
37
|
-
CLI_LOCAL_SERVER_END_PORT = 8005
|
|
38
|
-
|
|
39
|
-
# HTTP request timeout (30 seconds)
|
|
40
|
-
HTTP_TIMEOUT = 30.0
|
|
41
|
-
|
|
42
|
-
# Global storage for OAuth callback data
|
|
43
|
-
oauth_data = {"code": None, "state": None, "error": None}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class AuthCallbackHandler(BaseHTTPRequestHandler):
|
|
47
|
-
"""HTTP handler for OAuth redirect callback."""
|
|
48
|
-
|
|
49
|
-
def do_GET(self):
|
|
50
|
-
global oauth_data
|
|
51
|
-
self.send_response(200)
|
|
52
|
-
self.send_header("Content-type", "text/html")
|
|
53
|
-
self.end_headers()
|
|
54
|
-
|
|
55
|
-
query_params = parse_qs(urlparse(self.path).query)
|
|
56
|
-
|
|
57
|
-
if "code" in query_params:
|
|
58
|
-
oauth_data["code"] = query_params["code"][0]
|
|
59
|
-
oauth_data["state"] = query_params["state"][0] if "state" in query_params else None
|
|
60
|
-
self.wfile.write(
|
|
61
|
-
b"<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>"
|
|
62
|
-
)
|
|
63
|
-
elif "error" in query_params:
|
|
64
|
-
oauth_data["error"] = query_params["error"][0]
|
|
65
|
-
self.wfile.write(
|
|
66
|
-
f"<html><body><h1>Authentication failed!</h1><p>Error: {oauth_data['error']}</p></body></html>".encode()
|
|
67
|
-
)
|
|
68
|
-
else:
|
|
69
|
-
self.wfile.write(
|
|
70
|
-
b"<html><body><h1>Authentication callback received.</h1><p>Waiting for code...</p></body></html>"
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
# Shut down the server after processing
|
|
74
|
-
self.server.shutdown() # type: ignore
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def run_local_oauth_server(port: int, redirect_path: str):
|
|
78
|
-
"""Start a local HTTP server to capture the OAuth redirect."""
|
|
79
|
-
server_address = ("127.0.0.1", port)
|
|
80
|
-
httpd = HTTPServer(server_address, AuthCallbackHandler)
|
|
81
|
-
httpd.timeout = 30 # seconds
|
|
82
|
-
console.print(
|
|
83
|
-
f"[dim]Listening for OAuth redirect on http://localhost:{port}{redirect_path}...[/dim]"
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
# Store the server instance in the handler for shutdown
|
|
87
|
-
AuthCallbackHandler.server = httpd # type: ignore
|
|
88
|
-
|
|
89
|
-
# Handle a single request (blocking call)
|
|
90
|
-
httpd.handle_request()
|
|
91
|
-
console.print("[dim]Local OAuth server shut down.[/dim]")
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
@retry(
|
|
95
|
-
stop=stop_after_attempt(3),
|
|
96
|
-
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
97
|
-
retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
|
|
98
|
-
reraise=True,
|
|
99
|
-
)
|
|
100
|
-
def _refresh_token_with_retry(refresh_token: str) -> dict:
|
|
101
|
-
"""
|
|
102
|
-
Refresh access token with retry logic.
|
|
103
|
-
|
|
104
|
-
Returns token data dictionary.
|
|
105
|
-
"""
|
|
106
|
-
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
|
107
|
-
response = client.post(
|
|
108
|
-
f"{API_BASE_URL}/auth/refresh",
|
|
109
|
-
data={"refresh_token": refresh_token, "client_id": CLI_CLIENT_ID},
|
|
110
|
-
headers={"Accept": "application/json"},
|
|
111
|
-
)
|
|
112
|
-
response.raise_for_status()
|
|
113
|
-
|
|
114
|
-
# Safe JSON parsing with content-type check
|
|
115
|
-
content_type = response.headers.get("content-type", "")
|
|
116
|
-
if "application/json" not in content_type:
|
|
117
|
-
raise ValueError(f"Expected JSON response, got {content_type}")
|
|
118
|
-
|
|
119
|
-
try:
|
|
120
|
-
token_data = response.json()
|
|
121
|
-
except (ValueError, TypeError) as e:
|
|
122
|
-
raise ValueError(f"Failed to parse JSON response: {e}")
|
|
123
|
-
|
|
124
|
-
return token_data
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def _get_encryption_key() -> bytes:
|
|
128
|
-
"""
|
|
129
|
-
Get or create encryption key for file-based token storage.
|
|
130
|
-
|
|
131
|
-
Uses machine-specific identifier to generate key (not perfect but better than plaintext).
|
|
132
|
-
"""
|
|
133
|
-
import platform
|
|
134
|
-
import hashlib
|
|
135
|
-
|
|
136
|
-
# Use machine ID + username as seed (available on Windows/Linux/Mac)
|
|
137
|
-
machine_id = platform.node() + os.getlogin()
|
|
138
|
-
key = hashlib.sha256(machine_id.encode()).digest()[:32] # 32 bytes for Fernet
|
|
139
|
-
return key
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def _get_token_from_file(key: str) -> str | None:
|
|
143
|
-
"""Fallback: Get token from file storage (for Windows if keyring fails)."""
|
|
144
|
-
try:
|
|
145
|
-
if TOKEN_FILE.exists():
|
|
146
|
-
with open(TOKEN_FILE, "rb") as f:
|
|
147
|
-
encrypted_data = f.read()
|
|
148
|
-
|
|
149
|
-
# Decrypt the file contents
|
|
150
|
-
from cryptography.fernet import Fernet
|
|
151
|
-
import base64
|
|
152
|
-
|
|
153
|
-
fernet_key = base64.urlsafe_b64encode(_get_encryption_key())
|
|
154
|
-
cipher = Fernet(fernet_key)
|
|
155
|
-
decrypted_data = cipher.decrypt(encrypted_data)
|
|
156
|
-
tokens = json.loads(decrypted_data.decode())
|
|
157
|
-
return tokens.get(key)
|
|
158
|
-
except Exception:
|
|
159
|
-
# If decryption fails, try reading as plaintext (for backward compatibility)
|
|
160
|
-
try:
|
|
161
|
-
with open(TOKEN_FILE, "r") as f:
|
|
162
|
-
tokens = json.load(f)
|
|
163
|
-
return tokens.get(key)
|
|
164
|
-
except Exception:
|
|
165
|
-
pass
|
|
166
|
-
return None
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def _set_token_to_file(key: str, value: str):
|
|
170
|
-
"""Fallback: Save token to file storage (encrypted for Windows if keyring fails)."""
|
|
171
|
-
try:
|
|
172
|
-
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
-
|
|
174
|
-
# Load existing tokens (try encrypted first, then plaintext)
|
|
175
|
-
tokens = {}
|
|
176
|
-
if TOKEN_FILE.exists():
|
|
177
|
-
try:
|
|
178
|
-
with open(TOKEN_FILE, "rb") as f:
|
|
179
|
-
encrypted_data = f.read()
|
|
180
|
-
from cryptography.fernet import Fernet
|
|
181
|
-
import base64
|
|
182
|
-
|
|
183
|
-
fernet_key = base64.urlsafe_b64encode(_get_encryption_key())
|
|
184
|
-
cipher = Fernet(fernet_key)
|
|
185
|
-
decrypted_data = cipher.decrypt(encrypted_data)
|
|
186
|
-
tokens = json.loads(decrypted_data.decode())
|
|
187
|
-
except Exception:
|
|
188
|
-
# Fallback to plaintext for backward compatibility
|
|
189
|
-
try:
|
|
190
|
-
with open(TOKEN_FILE, "r") as f:
|
|
191
|
-
tokens = json.load(f)
|
|
192
|
-
except Exception:
|
|
193
|
-
tokens = {}
|
|
194
|
-
|
|
195
|
-
# Update token
|
|
196
|
-
tokens[key] = value
|
|
197
|
-
|
|
198
|
-
# Encrypt and save
|
|
199
|
-
from cryptography.fernet import Fernet
|
|
200
|
-
import base64
|
|
201
|
-
|
|
202
|
-
fernet_key = base64.urlsafe_b64encode(_get_encryption_key())
|
|
203
|
-
cipher = Fernet(fernet_key)
|
|
204
|
-
encrypted_data = cipher.encrypt(json.dumps(tokens).encode())
|
|
205
|
-
|
|
206
|
-
with open(TOKEN_FILE, "wb") as f:
|
|
207
|
-
f.write(encrypted_data)
|
|
208
|
-
|
|
209
|
-
# Set file permissions (owner read/write only) - works on Unix-like systems
|
|
210
|
-
if os.name != "nt": # Not Windows
|
|
211
|
-
TOKEN_FILE.chmod(0o600)
|
|
212
|
-
except Exception as e:
|
|
213
|
-
console.print(f"[yellow]Warning: Could not save token to file: {e}[/yellow]")
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def _delete_token_from_file(key: str):
|
|
217
|
-
"""Fallback: Delete token from file storage."""
|
|
218
|
-
try:
|
|
219
|
-
if TOKEN_FILE.exists():
|
|
220
|
-
with open(TOKEN_FILE, "r") as f:
|
|
221
|
-
tokens = json.load(f)
|
|
222
|
-
|
|
223
|
-
if key in tokens:
|
|
224
|
-
del tokens[key]
|
|
225
|
-
|
|
226
|
-
if tokens:
|
|
227
|
-
with open(TOKEN_FILE, "w") as f:
|
|
228
|
-
json.dump(tokens, f)
|
|
229
|
-
else:
|
|
230
|
-
TOKEN_FILE.unlink() # Delete file if empty
|
|
231
|
-
except Exception:
|
|
232
|
-
pass
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
def get_auth_token() -> str | None:
|
|
236
|
-
"""
|
|
237
|
-
Retrieve a valid access token, refreshing it if necessary.
|
|
238
|
-
|
|
239
|
-
Returns:
|
|
240
|
-
Valid access token or None if not authenticated
|
|
241
|
-
"""
|
|
242
|
-
# Try keyring first
|
|
243
|
-
access_token = None
|
|
244
|
-
refresh_token = None
|
|
245
|
-
use_file_fallback = False
|
|
246
|
-
|
|
247
|
-
try:
|
|
248
|
-
access_token = keyring.get_password(SERVICE_ID, "access_token")
|
|
249
|
-
refresh_token = keyring.get_password(SERVICE_ID, "refresh_token")
|
|
250
|
-
if os.getenv("XENFRA_DEBUG") == "1":
|
|
251
|
-
console.print("[dim]DEBUG: Token retrieved from keyring[/dim]")
|
|
252
|
-
except keyring.errors.KeyringError as e:
|
|
253
|
-
if os.getenv("XENFRA_DEBUG") == "1":
|
|
254
|
-
console.print(f"[dim]DEBUG: Keyring unavailable, using file storage: {e}[/dim]")
|
|
255
|
-
use_file_fallback = True
|
|
256
|
-
access_token = _get_token_from_file("access_token")
|
|
257
|
-
refresh_token = _get_token_from_file("refresh_token")
|
|
258
|
-
|
|
259
|
-
if not access_token:
|
|
260
|
-
return None
|
|
261
|
-
|
|
262
|
-
# Check if access token is expired
|
|
263
|
-
# Manually decode JWT payload to check expiration without verifying signature
|
|
264
|
-
try:
|
|
265
|
-
import base64
|
|
266
|
-
import json
|
|
267
|
-
|
|
268
|
-
# JWT format: header.payload.signature
|
|
269
|
-
parts = access_token.split(".")
|
|
270
|
-
if len(parts) != 3:
|
|
271
|
-
claims = None
|
|
272
|
-
else:
|
|
273
|
-
# Decode payload (second part)
|
|
274
|
-
payload_b64 = parts[1]
|
|
275
|
-
# Add padding if needed
|
|
276
|
-
padding = 4 - len(payload_b64) % 4
|
|
277
|
-
if padding != 4:
|
|
278
|
-
payload_b64 += "=" * padding
|
|
279
|
-
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
280
|
-
claims = json.loads(payload_bytes)
|
|
281
|
-
|
|
282
|
-
# Check expiration manually
|
|
283
|
-
exp = claims.get("exp")
|
|
284
|
-
if exp:
|
|
285
|
-
import time
|
|
286
|
-
if time.time() >= exp:
|
|
287
|
-
claims = None # Token expired
|
|
288
|
-
except Exception:
|
|
289
|
-
claims = None
|
|
290
|
-
|
|
291
|
-
# Refresh token if expired
|
|
292
|
-
if not claims and refresh_token:
|
|
293
|
-
console.print("[dim]Access token expired. Attempting to refresh...[/dim]")
|
|
294
|
-
try:
|
|
295
|
-
token_data = _refresh_token_with_retry(refresh_token)
|
|
296
|
-
new_access_token = token_data.get("access_token")
|
|
297
|
-
new_refresh_token = token_data.get("refresh_token")
|
|
298
|
-
|
|
299
|
-
if new_access_token:
|
|
300
|
-
# Save tokens (keyring or file fallback)
|
|
301
|
-
if use_file_fallback:
|
|
302
|
-
_set_token_to_file("access_token", new_access_token)
|
|
303
|
-
if new_refresh_token:
|
|
304
|
-
_set_token_to_file("refresh_token", new_refresh_token)
|
|
305
|
-
else:
|
|
306
|
-
try:
|
|
307
|
-
keyring.set_password(SERVICE_ID, "access_token", new_access_token)
|
|
308
|
-
if new_refresh_token:
|
|
309
|
-
keyring.set_password(SERVICE_ID, "refresh_token", new_refresh_token)
|
|
310
|
-
except keyring.errors.KeyringError as e:
|
|
311
|
-
console.print(f"[dim]Keyring failed, falling back to file storage: {e}[/dim]")
|
|
312
|
-
_set_token_to_file("access_token", new_access_token)
|
|
313
|
-
if new_refresh_token:
|
|
314
|
-
_set_token_to_file("refresh_token", new_refresh_token)
|
|
315
|
-
|
|
316
|
-
console.print("[bold green]Token refreshed successfully.[/bold green]")
|
|
317
|
-
return new_access_token
|
|
318
|
-
else:
|
|
319
|
-
console.print("[bold red]Failed to get new access token.[/bold red]")
|
|
320
|
-
return None
|
|
321
|
-
|
|
322
|
-
except httpx.TimeoutException:
|
|
323
|
-
console.print("[bold red]Token refresh failed: Request timed out.[/bold red]")
|
|
324
|
-
return None
|
|
325
|
-
except httpx.NetworkError:
|
|
326
|
-
console.print("[bold red]Token refresh failed: Network error.[/bold red]")
|
|
327
|
-
return None
|
|
328
|
-
except httpx.HTTPStatusError as exc:
|
|
329
|
-
if exc.response.status_code == 400:
|
|
330
|
-
console.print("[bold red]Refresh token expired. Please log in again.[/bold red]")
|
|
331
|
-
else:
|
|
332
|
-
error_detail = "Unknown error"
|
|
333
|
-
try:
|
|
334
|
-
if exc.response.content:
|
|
335
|
-
content_type = exc.response.headers.get("content-type", "")
|
|
336
|
-
if "application/json" in content_type:
|
|
337
|
-
error_data = exc.response.json()
|
|
338
|
-
error_detail = error_data.get("detail", str(error_data))
|
|
339
|
-
except Exception:
|
|
340
|
-
error_detail = exc.response.text[:200] if exc.response.text else "Unknown error"
|
|
341
|
-
|
|
342
|
-
console.print(
|
|
343
|
-
f"[bold red]Token refresh failed: {exc.response.status_code} - {error_detail}[/bold red]"
|
|
344
|
-
)
|
|
345
|
-
|
|
346
|
-
# Clear tokens on refresh failure
|
|
347
|
-
clear_tokens()
|
|
348
|
-
return None
|
|
349
|
-
except ValueError as e:
|
|
350
|
-
console.print(f"[bold red]Token refresh failed: {e}[/bold red]")
|
|
351
|
-
return None
|
|
352
|
-
except Exception as e:
|
|
353
|
-
console.print(
|
|
354
|
-
f"[bold red]Token refresh failed: Unexpected error - {type(e).__name__}[/bold red]"
|
|
355
|
-
)
|
|
356
|
-
return None
|
|
357
|
-
|
|
358
|
-
return access_token
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
def clear_tokens():
|
|
362
|
-
"""Clear stored access and refresh tokens (from both keyring and file)."""
|
|
363
|
-
# Clear from keyring
|
|
364
|
-
try:
|
|
365
|
-
keyring.delete_password(SERVICE_ID, "access_token")
|
|
366
|
-
keyring.delete_password(SERVICE_ID, "refresh_token")
|
|
367
|
-
except keyring.errors.PasswordDeleteError:
|
|
368
|
-
pass # Tokens already cleared
|
|
369
|
-
except keyring.errors.KeyringError:
|
|
370
|
-
pass # Keyring not available, that's OK
|
|
371
|
-
|
|
372
|
-
# Clear from file storage (fallback)
|
|
373
|
-
_delete_token_from_file("access_token")
|
|
374
|
-
_delete_token_from_file("refresh_token")
|
|
1
|
+
"""
|
|
2
|
+
Authentication utilities for Xenfra CLI.
|
|
3
|
+
Handles OAuth2 PKCE flow and token management.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import parse_qs, urlparse
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import keyring
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from tenacity import (
|
|
16
|
+
retry,
|
|
17
|
+
retry_if_exception_type,
|
|
18
|
+
stop_after_attempt,
|
|
19
|
+
wait_exponential,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from .security import validate_and_get_api_url
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
# Get validated API URL (includes all security checks)
|
|
27
|
+
API_BASE_URL = validate_and_get_api_url()
|
|
28
|
+
SERVICE_ID = "xenfra"
|
|
29
|
+
|
|
30
|
+
# Fallback file-based token storage (for Windows if keyring fails)
|
|
31
|
+
TOKEN_FILE = Path.home() / ".xenfra" / "tokens.json"
|
|
32
|
+
|
|
33
|
+
# CLI OAuth2 Configuration
|
|
34
|
+
CLI_CLIENT_ID = "xenfra-cli"
|
|
35
|
+
CLI_REDIRECT_PATH = "/auth/callback"
|
|
36
|
+
CLI_LOCAL_SERVER_START_PORT = 8001
|
|
37
|
+
CLI_LOCAL_SERVER_END_PORT = 8005
|
|
38
|
+
|
|
39
|
+
# HTTP request timeout (30 seconds)
|
|
40
|
+
HTTP_TIMEOUT = 30.0
|
|
41
|
+
|
|
42
|
+
# Global storage for OAuth callback data
|
|
43
|
+
oauth_data = {"code": None, "state": None, "error": None}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuthCallbackHandler(BaseHTTPRequestHandler):
|
|
47
|
+
"""HTTP handler for OAuth redirect callback."""
|
|
48
|
+
|
|
49
|
+
def do_GET(self):
|
|
50
|
+
global oauth_data
|
|
51
|
+
self.send_response(200)
|
|
52
|
+
self.send_header("Content-type", "text/html")
|
|
53
|
+
self.end_headers()
|
|
54
|
+
|
|
55
|
+
query_params = parse_qs(urlparse(self.path).query)
|
|
56
|
+
|
|
57
|
+
if "code" in query_params:
|
|
58
|
+
oauth_data["code"] = query_params["code"][0]
|
|
59
|
+
oauth_data["state"] = query_params["state"][0] if "state" in query_params else None
|
|
60
|
+
self.wfile.write(
|
|
61
|
+
b"<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>"
|
|
62
|
+
)
|
|
63
|
+
elif "error" in query_params:
|
|
64
|
+
oauth_data["error"] = query_params["error"][0]
|
|
65
|
+
self.wfile.write(
|
|
66
|
+
f"<html><body><h1>Authentication failed!</h1><p>Error: {oauth_data['error']}</p></body></html>".encode()
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
self.wfile.write(
|
|
70
|
+
b"<html><body><h1>Authentication callback received.</h1><p>Waiting for code...</p></body></html>"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Shut down the server after processing
|
|
74
|
+
self.server.shutdown() # type: ignore
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_local_oauth_server(port: int, redirect_path: str):
|
|
78
|
+
"""Start a local HTTP server to capture the OAuth redirect."""
|
|
79
|
+
server_address = ("127.0.0.1", port)
|
|
80
|
+
httpd = HTTPServer(server_address, AuthCallbackHandler)
|
|
81
|
+
httpd.timeout = 30 # seconds
|
|
82
|
+
console.print(
|
|
83
|
+
f"[dim]Listening for OAuth redirect on http://localhost:{port}{redirect_path}...[/dim]"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Store the server instance in the handler for shutdown
|
|
87
|
+
AuthCallbackHandler.server = httpd # type: ignore
|
|
88
|
+
|
|
89
|
+
# Handle a single request (blocking call)
|
|
90
|
+
httpd.handle_request()
|
|
91
|
+
console.print("[dim]Local OAuth server shut down.[/dim]")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@retry(
|
|
95
|
+
stop=stop_after_attempt(3),
|
|
96
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
97
|
+
retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
|
|
98
|
+
reraise=True,
|
|
99
|
+
)
|
|
100
|
+
def _refresh_token_with_retry(refresh_token: str) -> dict:
|
|
101
|
+
"""
|
|
102
|
+
Refresh access token with retry logic.
|
|
103
|
+
|
|
104
|
+
Returns token data dictionary.
|
|
105
|
+
"""
|
|
106
|
+
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
|
107
|
+
response = client.post(
|
|
108
|
+
f"{API_BASE_URL}/auth/refresh",
|
|
109
|
+
data={"refresh_token": refresh_token, "client_id": CLI_CLIENT_ID},
|
|
110
|
+
headers={"Accept": "application/json"},
|
|
111
|
+
)
|
|
112
|
+
response.raise_for_status()
|
|
113
|
+
|
|
114
|
+
# Safe JSON parsing with content-type check
|
|
115
|
+
content_type = response.headers.get("content-type", "")
|
|
116
|
+
if "application/json" not in content_type:
|
|
117
|
+
raise ValueError(f"Expected JSON response, got {content_type}")
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
token_data = response.json()
|
|
121
|
+
except (ValueError, TypeError) as e:
|
|
122
|
+
raise ValueError(f"Failed to parse JSON response: {e}")
|
|
123
|
+
|
|
124
|
+
return token_data
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _get_encryption_key() -> bytes:
|
|
128
|
+
"""
|
|
129
|
+
Get or create encryption key for file-based token storage.
|
|
130
|
+
|
|
131
|
+
Uses machine-specific identifier to generate key (not perfect but better than plaintext).
|
|
132
|
+
"""
|
|
133
|
+
import platform
|
|
134
|
+
import hashlib
|
|
135
|
+
|
|
136
|
+
# Use machine ID + username as seed (available on Windows/Linux/Mac)
|
|
137
|
+
machine_id = platform.node() + os.getlogin()
|
|
138
|
+
key = hashlib.sha256(machine_id.encode()).digest()[:32] # 32 bytes for Fernet
|
|
139
|
+
return key
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _get_token_from_file(key: str) -> str | None:
|
|
143
|
+
"""Fallback: Get token from file storage (for Windows if keyring fails)."""
|
|
144
|
+
try:
|
|
145
|
+
if TOKEN_FILE.exists():
|
|
146
|
+
with open(TOKEN_FILE, "rb") as f:
|
|
147
|
+
encrypted_data = f.read()
|
|
148
|
+
|
|
149
|
+
# Decrypt the file contents
|
|
150
|
+
from cryptography.fernet import Fernet
|
|
151
|
+
import base64
|
|
152
|
+
|
|
153
|
+
fernet_key = base64.urlsafe_b64encode(_get_encryption_key())
|
|
154
|
+
cipher = Fernet(fernet_key)
|
|
155
|
+
decrypted_data = cipher.decrypt(encrypted_data)
|
|
156
|
+
tokens = json.loads(decrypted_data.decode())
|
|
157
|
+
return tokens.get(key)
|
|
158
|
+
except Exception:
|
|
159
|
+
# If decryption fails, try reading as plaintext (for backward compatibility)
|
|
160
|
+
try:
|
|
161
|
+
with open(TOKEN_FILE, "r") as f:
|
|
162
|
+
tokens = json.load(f)
|
|
163
|
+
return tokens.get(key)
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _set_token_to_file(key: str, value: str):
|
|
170
|
+
"""Fallback: Save token to file storage (encrypted for Windows if keyring fails)."""
|
|
171
|
+
try:
|
|
172
|
+
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
|
|
174
|
+
# Load existing tokens (try encrypted first, then plaintext)
|
|
175
|
+
tokens = {}
|
|
176
|
+
if TOKEN_FILE.exists():
|
|
177
|
+
try:
|
|
178
|
+
with open(TOKEN_FILE, "rb") as f:
|
|
179
|
+
encrypted_data = f.read()
|
|
180
|
+
from cryptography.fernet import Fernet
|
|
181
|
+
import base64
|
|
182
|
+
|
|
183
|
+
fernet_key = base64.urlsafe_b64encode(_get_encryption_key())
|
|
184
|
+
cipher = Fernet(fernet_key)
|
|
185
|
+
decrypted_data = cipher.decrypt(encrypted_data)
|
|
186
|
+
tokens = json.loads(decrypted_data.decode())
|
|
187
|
+
except Exception:
|
|
188
|
+
# Fallback to plaintext for backward compatibility
|
|
189
|
+
try:
|
|
190
|
+
with open(TOKEN_FILE, "r") as f:
|
|
191
|
+
tokens = json.load(f)
|
|
192
|
+
except Exception:
|
|
193
|
+
tokens = {}
|
|
194
|
+
|
|
195
|
+
# Update token
|
|
196
|
+
tokens[key] = value
|
|
197
|
+
|
|
198
|
+
# Encrypt and save
|
|
199
|
+
from cryptography.fernet import Fernet
|
|
200
|
+
import base64
|
|
201
|
+
|
|
202
|
+
fernet_key = base64.urlsafe_b64encode(_get_encryption_key())
|
|
203
|
+
cipher = Fernet(fernet_key)
|
|
204
|
+
encrypted_data = cipher.encrypt(json.dumps(tokens).encode())
|
|
205
|
+
|
|
206
|
+
with open(TOKEN_FILE, "wb") as f:
|
|
207
|
+
f.write(encrypted_data)
|
|
208
|
+
|
|
209
|
+
# Set file permissions (owner read/write only) - works on Unix-like systems
|
|
210
|
+
if os.name != "nt": # Not Windows
|
|
211
|
+
TOKEN_FILE.chmod(0o600)
|
|
212
|
+
except Exception as e:
|
|
213
|
+
console.print(f"[yellow]Warning: Could not save token to file: {e}[/yellow]")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _delete_token_from_file(key: str):
|
|
217
|
+
"""Fallback: Delete token from file storage."""
|
|
218
|
+
try:
|
|
219
|
+
if TOKEN_FILE.exists():
|
|
220
|
+
with open(TOKEN_FILE, "r") as f:
|
|
221
|
+
tokens = json.load(f)
|
|
222
|
+
|
|
223
|
+
if key in tokens:
|
|
224
|
+
del tokens[key]
|
|
225
|
+
|
|
226
|
+
if tokens:
|
|
227
|
+
with open(TOKEN_FILE, "w") as f:
|
|
228
|
+
json.dump(tokens, f)
|
|
229
|
+
else:
|
|
230
|
+
TOKEN_FILE.unlink() # Delete file if empty
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def get_auth_token() -> str | None:
|
|
236
|
+
"""
|
|
237
|
+
Retrieve a valid access token, refreshing it if necessary.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Valid access token or None if not authenticated
|
|
241
|
+
"""
|
|
242
|
+
# Try keyring first
|
|
243
|
+
access_token = None
|
|
244
|
+
refresh_token = None
|
|
245
|
+
use_file_fallback = False
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
access_token = keyring.get_password(SERVICE_ID, "access_token")
|
|
249
|
+
refresh_token = keyring.get_password(SERVICE_ID, "refresh_token")
|
|
250
|
+
if os.getenv("XENFRA_DEBUG") == "1":
|
|
251
|
+
console.print("[dim]DEBUG: Token retrieved from keyring[/dim]")
|
|
252
|
+
except keyring.errors.KeyringError as e:
|
|
253
|
+
if os.getenv("XENFRA_DEBUG") == "1":
|
|
254
|
+
console.print(f"[dim]DEBUG: Keyring unavailable, using file storage: {e}[/dim]")
|
|
255
|
+
use_file_fallback = True
|
|
256
|
+
access_token = _get_token_from_file("access_token")
|
|
257
|
+
refresh_token = _get_token_from_file("refresh_token")
|
|
258
|
+
|
|
259
|
+
if not access_token:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
# Check if access token is expired
|
|
263
|
+
# Manually decode JWT payload to check expiration without verifying signature
|
|
264
|
+
try:
|
|
265
|
+
import base64
|
|
266
|
+
import json
|
|
267
|
+
|
|
268
|
+
# JWT format: header.payload.signature
|
|
269
|
+
parts = access_token.split(".")
|
|
270
|
+
if len(parts) != 3:
|
|
271
|
+
claims = None
|
|
272
|
+
else:
|
|
273
|
+
# Decode payload (second part)
|
|
274
|
+
payload_b64 = parts[1]
|
|
275
|
+
# Add padding if needed
|
|
276
|
+
padding = 4 - len(payload_b64) % 4
|
|
277
|
+
if padding != 4:
|
|
278
|
+
payload_b64 += "=" * padding
|
|
279
|
+
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
280
|
+
claims = json.loads(payload_bytes)
|
|
281
|
+
|
|
282
|
+
# Check expiration manually
|
|
283
|
+
exp = claims.get("exp")
|
|
284
|
+
if exp:
|
|
285
|
+
import time
|
|
286
|
+
if time.time() >= exp:
|
|
287
|
+
claims = None # Token expired
|
|
288
|
+
except Exception:
|
|
289
|
+
claims = None
|
|
290
|
+
|
|
291
|
+
# Refresh token if expired
|
|
292
|
+
if not claims and refresh_token:
|
|
293
|
+
console.print("[dim]Access token expired. Attempting to refresh...[/dim]")
|
|
294
|
+
try:
|
|
295
|
+
token_data = _refresh_token_with_retry(refresh_token)
|
|
296
|
+
new_access_token = token_data.get("access_token")
|
|
297
|
+
new_refresh_token = token_data.get("refresh_token")
|
|
298
|
+
|
|
299
|
+
if new_access_token:
|
|
300
|
+
# Save tokens (keyring or file fallback)
|
|
301
|
+
if use_file_fallback:
|
|
302
|
+
_set_token_to_file("access_token", new_access_token)
|
|
303
|
+
if new_refresh_token:
|
|
304
|
+
_set_token_to_file("refresh_token", new_refresh_token)
|
|
305
|
+
else:
|
|
306
|
+
try:
|
|
307
|
+
keyring.set_password(SERVICE_ID, "access_token", new_access_token)
|
|
308
|
+
if new_refresh_token:
|
|
309
|
+
keyring.set_password(SERVICE_ID, "refresh_token", new_refresh_token)
|
|
310
|
+
except keyring.errors.KeyringError as e:
|
|
311
|
+
console.print(f"[dim]Keyring failed, falling back to file storage: {e}[/dim]")
|
|
312
|
+
_set_token_to_file("access_token", new_access_token)
|
|
313
|
+
if new_refresh_token:
|
|
314
|
+
_set_token_to_file("refresh_token", new_refresh_token)
|
|
315
|
+
|
|
316
|
+
console.print("[bold green]Token refreshed successfully.[/bold green]")
|
|
317
|
+
return new_access_token
|
|
318
|
+
else:
|
|
319
|
+
console.print("[bold red]Failed to get new access token.[/bold red]")
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
except httpx.TimeoutException:
|
|
323
|
+
console.print("[bold red]Token refresh failed: Request timed out.[/bold red]")
|
|
324
|
+
return None
|
|
325
|
+
except httpx.NetworkError:
|
|
326
|
+
console.print("[bold red]Token refresh failed: Network error.[/bold red]")
|
|
327
|
+
return None
|
|
328
|
+
except httpx.HTTPStatusError as exc:
|
|
329
|
+
if exc.response.status_code == 400:
|
|
330
|
+
console.print("[bold red]Refresh token expired. Please log in again.[/bold red]")
|
|
331
|
+
else:
|
|
332
|
+
error_detail = "Unknown error"
|
|
333
|
+
try:
|
|
334
|
+
if exc.response.content:
|
|
335
|
+
content_type = exc.response.headers.get("content-type", "")
|
|
336
|
+
if "application/json" in content_type:
|
|
337
|
+
error_data = exc.response.json()
|
|
338
|
+
error_detail = error_data.get("detail", str(error_data))
|
|
339
|
+
except Exception:
|
|
340
|
+
error_detail = exc.response.text[:200] if exc.response.text else "Unknown error"
|
|
341
|
+
|
|
342
|
+
console.print(
|
|
343
|
+
f"[bold red]Token refresh failed: {exc.response.status_code} - {error_detail}[/bold red]"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Clear tokens on refresh failure
|
|
347
|
+
clear_tokens()
|
|
348
|
+
return None
|
|
349
|
+
except ValueError as e:
|
|
350
|
+
console.print(f"[bold red]Token refresh failed: {e}[/bold red]")
|
|
351
|
+
return None
|
|
352
|
+
except Exception as e:
|
|
353
|
+
console.print(
|
|
354
|
+
f"[bold red]Token refresh failed: Unexpected error - {type(e).__name__}[/bold red]"
|
|
355
|
+
)
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
return access_token
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def clear_tokens():
|
|
362
|
+
"""Clear stored access and refresh tokens (from both keyring and file)."""
|
|
363
|
+
# Clear from keyring
|
|
364
|
+
try:
|
|
365
|
+
keyring.delete_password(SERVICE_ID, "access_token")
|
|
366
|
+
keyring.delete_password(SERVICE_ID, "refresh_token")
|
|
367
|
+
except keyring.errors.PasswordDeleteError:
|
|
368
|
+
pass # Tokens already cleared
|
|
369
|
+
except keyring.errors.KeyringError:
|
|
370
|
+
pass # Keyring not available, that's OK
|
|
371
|
+
|
|
372
|
+
# Clear from file storage (fallback)
|
|
373
|
+
_delete_token_from_file("access_token")
|
|
374
|
+
_delete_token_from_file("refresh_token")
|