xenfra 0.3.1__tar.gz → 0.3.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: xenfra
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: A 'Zen Mode' infrastructure engine for Python developers.
5
5
  Author: xenfra-cloud
6
6
  Author-email: xenfra-cloud <xenfracloud@gmail.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xenfra"
3
- version = "0.3.1"
3
+ version = "0.3.3"
4
4
  description = "A 'Zen Mode' infrastructure engine for Python developers."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -114,22 +114,29 @@ def whoami(token):
114
114
  return
115
115
 
116
116
  try:
117
- from jose import jwt
118
-
119
- # For display purposes only, in a CLI context where the token has just
120
- # been retrieved from a secure source (keyring), we can disable
121
- # signature verification.
122
- #
123
- # SECURITY BEST PRACTICE: In a real application, especially a server,
124
- # you would fetch the public key from the SSO's JWKS endpoint and
125
- # fully verify the token's signature to ensure its integrity.
126
- claims = jwt.decode(
127
- access_token, options={"verify_signature": False} # OK for local display
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" User ID: {claims.get('sub')}")
132
- console.print(f" Email: {claims.get('email', 'N/A')}")
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}")
@@ -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 in keyring
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"[yellow]Warning: Could not save tokens to keyring: {e}[/yellow]")
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]")
@@ -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
 
@@ -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"[yellow]Warning: Could not access keyring: {e}[/yellow]")
133
- return None
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
- try:
177
- keyring.set_password(SERVICE_ID, "access_token", new_access_token)
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
- keyring.set_password(SERVICE_ID, "refresh_token", new_refresh_token)
180
- console.print("[bold green]Token refreshed successfully.[/bold green]")
181
- return new_access_token
182
- except keyring.errors.KeyringError as e:
183
- console.print(
184
- f"[yellow]Warning: Could not save refreshed token to keyring: {e}[/yellow]"
185
- )
186
- # Return the token anyway, but warn user
187
- return new_access_token
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
- try:
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 as e:
243
- console.print(f"[yellow]Warning: Could not clear tokens from keyring: {e}[/yellow]")
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")
File without changes
File without changes
File without changes