xenfra 0.3.1__py3-none-any.whl → 0.3.3__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/auth.py +21 -14
- xenfra/commands/auth_device.py +7 -2
- xenfra/commands/intelligence.py +35 -0
- xenfra/utils/auth.py +98 -21
- {xenfra-0.3.1.dist-info → xenfra-0.3.3.dist-info}/METADATA +1 -1
- {xenfra-0.3.1.dist-info → xenfra-0.3.3.dist-info}/RECORD +8 -8
- {xenfra-0.3.1.dist-info → xenfra-0.3.3.dist-info}/WHEEL +0 -0
- {xenfra-0.3.1.dist-info → xenfra-0.3.3.dist-info}/entry_points.txt +0 -0
xenfra/commands/auth.py
CHANGED
|
@@ -114,22 +114,29 @@ def whoami(token):
|
|
|
114
114
|
return
|
|
115
115
|
|
|
116
116
|
try:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
117
|
+
import base64
|
|
118
|
+
import json
|
|
119
|
+
|
|
120
|
+
# Manually decode JWT payload without verification
|
|
121
|
+
# JWT format: header.payload.signature
|
|
122
|
+
parts = access_token.split(".")
|
|
123
|
+
if len(parts) != 3:
|
|
124
|
+
console.print("[bold red]Invalid token format[/bold red]")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# Decode payload (second part)
|
|
128
|
+
payload_b64 = parts[1]
|
|
129
|
+
# Add padding if needed
|
|
130
|
+
padding = 4 - len(payload_b64) % 4
|
|
131
|
+
if padding != 4:
|
|
132
|
+
payload_b64 += "=" * padding
|
|
133
|
+
|
|
134
|
+
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
135
|
+
claims = json.loads(payload_bytes)
|
|
129
136
|
|
|
130
137
|
console.print("[bold green]Logged in as:[/bold green]")
|
|
131
|
-
console.print(f"
|
|
132
|
-
console.print(f"
|
|
138
|
+
console.print(f" Email: {claims.get('sub', 'N/A')}")
|
|
139
|
+
console.print(f" User ID: {claims.get('user_id', 'N/A')}")
|
|
133
140
|
|
|
134
141
|
if token:
|
|
135
142
|
console.print(f"\n[dim]Access Token:[/dim]\n{access_token}")
|
xenfra/commands/auth_device.py
CHANGED
|
@@ -105,13 +105,18 @@ def device_login():
|
|
|
105
105
|
access_token = token_data["access_token"]
|
|
106
106
|
refresh_token = token_data.get("refresh_token")
|
|
107
107
|
|
|
108
|
-
# Store tokens
|
|
108
|
+
# Store tokens (keyring or file fallback)
|
|
109
109
|
try:
|
|
110
110
|
keyring.set_password(SERVICE_ID, "access_token", access_token)
|
|
111
111
|
if refresh_token:
|
|
112
112
|
keyring.set_password(SERVICE_ID, "refresh_token", refresh_token)
|
|
113
113
|
except keyring.errors.KeyringError as e:
|
|
114
|
-
console.print(f"[
|
|
114
|
+
console.print(f"[dim]Keyring unavailable, using file storage: {e}[/dim]")
|
|
115
|
+
# Fallback to file storage
|
|
116
|
+
from ..utils.auth import _set_token_to_file
|
|
117
|
+
_set_token_to_file("access_token", access_token)
|
|
118
|
+
if refresh_token:
|
|
119
|
+
_set_token_to_file("refresh_token", refresh_token)
|
|
115
120
|
|
|
116
121
|
console.print()
|
|
117
122
|
console.print("[bold green]✓ Successfully authenticated![/bold green]")
|
xenfra/commands/intelligence.py
CHANGED
|
@@ -34,6 +34,41 @@ def get_client() -> XenfraClient:
|
|
|
34
34
|
console.print("[bold red]Not logged in. Run 'xenfra login' first.[/bold red]")
|
|
35
35
|
raise click.Abort()
|
|
36
36
|
|
|
37
|
+
# DEBUG: Show token details
|
|
38
|
+
import base64
|
|
39
|
+
import json
|
|
40
|
+
try:
|
|
41
|
+
parts = token.split(".")
|
|
42
|
+
if len(parts) == 3:
|
|
43
|
+
# Decode payload
|
|
44
|
+
payload_b64 = parts[1]
|
|
45
|
+
padding = 4 - len(payload_b64) % 4
|
|
46
|
+
if padding != 4:
|
|
47
|
+
payload_b64 += "=" * padding
|
|
48
|
+
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
49
|
+
claims = json.loads(payload_bytes)
|
|
50
|
+
|
|
51
|
+
console.print("[dim]━━━ DEBUG: Token Info ━━━[/dim]")
|
|
52
|
+
console.print(f"[dim] API URL: {API_BASE_URL}[/dim]")
|
|
53
|
+
console.print(f"[dim] Token prefix: {token[:20]}...[/dim]")
|
|
54
|
+
console.print(f"[dim] sub (email): {claims.get('sub', 'MISSING')}[/dim]")
|
|
55
|
+
console.print(f"[dim] user_id: {claims.get('user_id', 'MISSING')}[/dim]")
|
|
56
|
+
console.print(f"[dim] iss (issuer): {claims.get('iss', 'MISSING')}[/dim]")
|
|
57
|
+
console.print(f"[dim] aud (audience): {claims.get('aud', 'MISSING')}[/dim]")
|
|
58
|
+
|
|
59
|
+
# Check if token is expired
|
|
60
|
+
exp = claims.get('exp')
|
|
61
|
+
if exp:
|
|
62
|
+
import time
|
|
63
|
+
is_expired = time.time() >= exp
|
|
64
|
+
from datetime import datetime, timezone
|
|
65
|
+
exp_time = datetime.fromtimestamp(exp, tz=timezone.utc)
|
|
66
|
+
console.print(f"[dim] expires_at: {exp_time}[/dim]")
|
|
67
|
+
console.print(f"[dim] expired: {is_expired}[/dim]")
|
|
68
|
+
console.print("[dim]━━━━━━━━━━━━━━━━━━━━━[/dim]\n")
|
|
69
|
+
except Exception as e:
|
|
70
|
+
console.print(f"[dim]DEBUG: Could not decode token: {e}[/dim]\n")
|
|
71
|
+
|
|
37
72
|
return XenfraClient(token=token, api_url=API_BASE_URL)
|
|
38
73
|
|
|
39
74
|
|
xenfra/utils/auth.py
CHANGED
|
@@ -3,7 +3,10 @@ Authentication utilities for Xenfra CLI.
|
|
|
3
3
|
Handles OAuth2 PKCE flow and token management.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
6
8
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
9
|
+
from pathlib import Path
|
|
7
10
|
from urllib.parse import parse_qs, urlparse
|
|
8
11
|
|
|
9
12
|
import httpx
|
|
@@ -24,6 +27,9 @@ console = Console()
|
|
|
24
27
|
API_BASE_URL = validate_and_get_api_url()
|
|
25
28
|
SERVICE_ID = "xenfra"
|
|
26
29
|
|
|
30
|
+
# Fallback file-based token storage (for Windows if keyring fails)
|
|
31
|
+
TOKEN_FILE = Path.home() / ".xenfra" / "tokens.json"
|
|
32
|
+
|
|
27
33
|
# CLI OAuth2 Configuration
|
|
28
34
|
CLI_CLIENT_ID = "xenfra-cli"
|
|
29
35
|
CLI_REDIRECT_PATH = "/auth/callback"
|
|
@@ -118,6 +124,62 @@ def _refresh_token_with_retry(refresh_token: str) -> dict:
|
|
|
118
124
|
return token_data
|
|
119
125
|
|
|
120
126
|
|
|
127
|
+
def _get_token_from_file(key: str) -> str | None:
|
|
128
|
+
"""Fallback: Get token from file storage (for Windows if keyring fails)."""
|
|
129
|
+
try:
|
|
130
|
+
if TOKEN_FILE.exists():
|
|
131
|
+
with open(TOKEN_FILE, "r") as f:
|
|
132
|
+
tokens = json.load(f)
|
|
133
|
+
return tokens.get(key)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _set_token_to_file(key: str, value: str):
|
|
140
|
+
"""Fallback: Save token to file storage (for Windows if keyring fails)."""
|
|
141
|
+
try:
|
|
142
|
+
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
|
|
144
|
+
# Load existing tokens
|
|
145
|
+
tokens = {}
|
|
146
|
+
if TOKEN_FILE.exists():
|
|
147
|
+
with open(TOKEN_FILE, "r") as f:
|
|
148
|
+
tokens = json.load(f)
|
|
149
|
+
|
|
150
|
+
# Update token
|
|
151
|
+
tokens[key] = value
|
|
152
|
+
|
|
153
|
+
# Save (with restrictive permissions on Unix-like systems)
|
|
154
|
+
with open(TOKEN_FILE, "w") as f:
|
|
155
|
+
json.dump(tokens, f)
|
|
156
|
+
|
|
157
|
+
# Set file permissions (owner read/write only)
|
|
158
|
+
if os.name != "nt": # Not Windows
|
|
159
|
+
TOKEN_FILE.chmod(0o600)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
console.print(f"[yellow]Warning: Could not save token to file: {e}[/yellow]")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _delete_token_from_file(key: str):
|
|
165
|
+
"""Fallback: Delete token from file storage."""
|
|
166
|
+
try:
|
|
167
|
+
if TOKEN_FILE.exists():
|
|
168
|
+
with open(TOKEN_FILE, "r") as f:
|
|
169
|
+
tokens = json.load(f)
|
|
170
|
+
|
|
171
|
+
if key in tokens:
|
|
172
|
+
del tokens[key]
|
|
173
|
+
|
|
174
|
+
if tokens:
|
|
175
|
+
with open(TOKEN_FILE, "w") as f:
|
|
176
|
+
json.dump(tokens, f)
|
|
177
|
+
else:
|
|
178
|
+
TOKEN_FILE.unlink() # Delete file if empty
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
|
|
121
183
|
def get_auth_token() -> str | None:
|
|
122
184
|
"""
|
|
123
185
|
Retrieve a valid access token, refreshing it if necessary.
|
|
@@ -125,12 +187,20 @@ def get_auth_token() -> str | None:
|
|
|
125
187
|
Returns:
|
|
126
188
|
Valid access token or None if not authenticated
|
|
127
189
|
"""
|
|
190
|
+
# Try keyring first
|
|
191
|
+
access_token = None
|
|
192
|
+
refresh_token = None
|
|
193
|
+
use_file_fallback = False
|
|
194
|
+
|
|
128
195
|
try:
|
|
129
196
|
access_token = keyring.get_password(SERVICE_ID, "access_token")
|
|
130
197
|
refresh_token = keyring.get_password(SERVICE_ID, "refresh_token")
|
|
198
|
+
console.print("[dim]DEBUG: Token retrieved from keyring[/dim]")
|
|
131
199
|
except keyring.errors.KeyringError as e:
|
|
132
|
-
console.print(f"[
|
|
133
|
-
|
|
200
|
+
console.print(f"[dim]DEBUG: Keyring unavailable, using file storage: {e}[/dim]")
|
|
201
|
+
use_file_fallback = True
|
|
202
|
+
access_token = _get_token_from_file("access_token")
|
|
203
|
+
refresh_token = _get_token_from_file("refresh_token")
|
|
134
204
|
|
|
135
205
|
if not access_token:
|
|
136
206
|
return None
|
|
@@ -173,18 +243,24 @@ def get_auth_token() -> str | None:
|
|
|
173
243
|
new_refresh_token = token_data.get("refresh_token")
|
|
174
244
|
|
|
175
245
|
if new_access_token:
|
|
176
|
-
|
|
177
|
-
|
|
246
|
+
# Save tokens (keyring or file fallback)
|
|
247
|
+
if use_file_fallback:
|
|
248
|
+
_set_token_to_file("access_token", new_access_token)
|
|
178
249
|
if new_refresh_token:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
250
|
+
_set_token_to_file("refresh_token", new_refresh_token)
|
|
251
|
+
else:
|
|
252
|
+
try:
|
|
253
|
+
keyring.set_password(SERVICE_ID, "access_token", new_access_token)
|
|
254
|
+
if new_refresh_token:
|
|
255
|
+
keyring.set_password(SERVICE_ID, "refresh_token", new_refresh_token)
|
|
256
|
+
except keyring.errors.KeyringError as e:
|
|
257
|
+
console.print(f"[dim]Keyring failed, falling back to file storage: {e}[/dim]")
|
|
258
|
+
_set_token_to_file("access_token", new_access_token)
|
|
259
|
+
if new_refresh_token:
|
|
260
|
+
_set_token_to_file("refresh_token", new_refresh_token)
|
|
261
|
+
|
|
262
|
+
console.print("[bold green]Token refreshed successfully.[/bold green]")
|
|
263
|
+
return new_access_token
|
|
188
264
|
else:
|
|
189
265
|
console.print("[bold red]Failed to get new access token.[/bold red]")
|
|
190
266
|
return None
|
|
@@ -214,11 +290,7 @@ def get_auth_token() -> str | None:
|
|
|
214
290
|
)
|
|
215
291
|
|
|
216
292
|
# Clear tokens on refresh failure
|
|
217
|
-
|
|
218
|
-
keyring.delete_password(SERVICE_ID, "access_token")
|
|
219
|
-
keyring.delete_password(SERVICE_ID, "refresh_token")
|
|
220
|
-
except keyring.errors.KeyringError:
|
|
221
|
-
pass # Ignore errors when clearing
|
|
293
|
+
clear_tokens()
|
|
222
294
|
return None
|
|
223
295
|
except ValueError as e:
|
|
224
296
|
console.print(f"[bold red]Token refresh failed: {e}[/bold red]")
|
|
@@ -233,11 +305,16 @@ def get_auth_token() -> str | None:
|
|
|
233
305
|
|
|
234
306
|
|
|
235
307
|
def clear_tokens():
|
|
236
|
-
"""Clear stored access and refresh tokens."""
|
|
308
|
+
"""Clear stored access and refresh tokens (from both keyring and file)."""
|
|
309
|
+
# Clear from keyring
|
|
237
310
|
try:
|
|
238
311
|
keyring.delete_password(SERVICE_ID, "access_token")
|
|
239
312
|
keyring.delete_password(SERVICE_ID, "refresh_token")
|
|
240
313
|
except keyring.errors.PasswordDeleteError:
|
|
241
314
|
pass # Tokens already cleared
|
|
242
|
-
except keyring.errors.KeyringError
|
|
243
|
-
|
|
315
|
+
except keyring.errors.KeyringError:
|
|
316
|
+
pass # Keyring not available, that's OK
|
|
317
|
+
|
|
318
|
+
# Clear from file storage (fallback)
|
|
319
|
+
_delete_token_from_file("access_token")
|
|
320
|
+
_delete_token_from_file("refresh_token")
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
xenfra/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
2
|
xenfra/commands/__init__.py,sha256=kTTwVnTvoxikyPUhQiyTAbnw4PYafktuE1----TqQoA,43
|
|
3
|
-
xenfra/commands/auth.py,sha256=
|
|
4
|
-
xenfra/commands/auth_device.py,sha256=
|
|
3
|
+
xenfra/commands/auth.py,sha256=ecReVCGl7Ys2d77mv_e4mCbs4ug6FLIb3S9dl2FUhr4,4178
|
|
4
|
+
xenfra/commands/auth_device.py,sha256=caD2UdveEZtAFjgjmnA-l5bjbbPONFjXJXgeJN7mhbk,6710
|
|
5
5
|
xenfra/commands/deployments.py,sha256=bI6d9lVYPhCDoQE3nke7s80MUQ75ILPqiEGhcbXDExo,28609
|
|
6
|
-
xenfra/commands/intelligence.py,sha256=
|
|
6
|
+
xenfra/commands/intelligence.py,sha256=leaGDzSG2rGrdNLvRHHZrWsRQXvnTj3-aX7T62xdk3Y,15572
|
|
7
7
|
xenfra/commands/projects.py,sha256=SAxF_pOr95K6uz35U-zENptKndKxJNZn6bcD45PHcpI,6696
|
|
8
8
|
xenfra/commands/security_cmd.py,sha256=EI5sjX2lcMxgMH-LCFmPVkc9YqadOrcoSgTiKknkVRY,7327
|
|
9
9
|
xenfra/main.py,sha256=2EPPuIdxjhW-I-e-Mc0i2ayeLaSJdmzddNThkXq7B7c,2033
|
|
10
10
|
xenfra/utils/__init__.py,sha256=4ZRYkrb--vzoXjBHG8zRxz2jCXNGtAoKNtkyu2WRI2A,45
|
|
11
|
-
xenfra/utils/auth.py,sha256=
|
|
11
|
+
xenfra/utils/auth.py,sha256=jyzwGrA7NEheUoggOgERMBl-45g0NNbNhjLDv8aK-SQ,11614
|
|
12
12
|
xenfra/utils/codebase.py,sha256=57GthXOvOQnUHiDwIHqxK6hNUGWlWf6Nfs3T8647Wrc,4144
|
|
13
13
|
xenfra/utils/config.py,sha256=F2zedd3JXP7TBdul0u8b4NVx-C1N6Hq4sH5szyWim6M,11947
|
|
14
14
|
xenfra/utils/security.py,sha256=EA8CIPLt8Y-QP5uZ7c5NuC6ZLRV1aZS8NapS9ix_vok,11479
|
|
15
15
|
xenfra/utils/validation.py,sha256=cvuL_AEFJ2oCoP0abCqoOIABOwz79Gkf-jh_dcFIQlM,6912
|
|
16
|
-
xenfra-0.3.
|
|
17
|
-
xenfra-0.3.
|
|
18
|
-
xenfra-0.3.
|
|
19
|
-
xenfra-0.3.
|
|
16
|
+
xenfra-0.3.3.dist-info/WHEEL,sha256=RRVLqVugUmFOqBedBFAmA4bsgFcROUBiSUKlERi0Hcg,79
|
|
17
|
+
xenfra-0.3.3.dist-info/entry_points.txt,sha256=a_2cGhYK__X6eW05Ba8uB6RIM_61c2sHtXsPY8N0mic,45
|
|
18
|
+
xenfra-0.3.3.dist-info/METADATA,sha256=uISd9cESqrhksNFb9QS9KpSeqOCs4lbs0_uT4r-5HJk,3834
|
|
19
|
+
xenfra-0.3.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|