xenfra 0.2.7__py3-none-any.whl → 0.2.9__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 CHANGED
@@ -85,165 +85,11 @@ def _exchange_code_for_tokens_with_retry(code: str, code_verifier: str, redirect
85
85
 
86
86
  @auth.command()
87
87
  def login():
88
- """Login to Xenfra using OAuth2 PKCE flow."""
89
- global oauth_data
90
- oauth_data = {"code": None, "state": None, "error": None}
88
+ """Login to Xenfra using Device Authorization Flow (like GitHub CLI, Claude Code)."""
89
+ from .auth_device import device_login
90
+ device_login()
91
91
 
92
- # 1. Generate PKCE parameters
93
- code_verifier = secrets.token_urlsafe(96)
94
- code_challenge = (
95
- base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
96
- .decode()
97
- .rstrip("=")
98
- )
99
-
100
- # 2. Generate state for CSRF protection
101
- state = secrets.token_urlsafe(32)
102
-
103
- # 3. Start local HTTP server
104
- server_port = None
105
- httpd_instance = None
106
- try:
107
- for port in range(CLI_LOCAL_SERVER_START_PORT, CLI_LOCAL_SERVER_END_PORT + 1):
108
- try:
109
- server_address = ("127.0.0.1", port)
110
- httpd_instance = HTTPServer(server_address, AuthCallbackHandler)
111
- server_port = port
112
- break
113
- except OSError:
114
- continue
115
- except Exception as e:
116
- console.print(f"[yellow]Warning: Failed to bind to port {port}: {e}[/yellow]")
117
- continue
118
-
119
- if not server_port:
120
- console.print(
121
- f"[bold red]Error: No available ports in range {CLI_LOCAL_SERVER_START_PORT}-{CLI_LOCAL_SERVER_END_PORT}[/bold red]"
122
- )
123
- return
124
-
125
- redirect_uri = f"http://localhost:{server_port}{CLI_REDIRECT_PATH}"
126
-
127
- # 4. Construct Authorization URL
128
- auth_url = (
129
- f"{API_BASE_URL}/auth/authorize?"
130
- f"client_id={CLI_CLIENT_ID}&"
131
- f"redirect_uri={urllib.parse.quote(redirect_uri)}&"
132
- f"response_type=code&"
133
- f"scope={urllib.parse.quote('openid profile')}&"
134
- f"state={state}&"
135
- f"code_challenge={code_challenge}&"
136
- f"code_challenge_method=S256"
137
- )
138
-
139
- console.print("[bold blue]Opening browser for login...[/bold blue]")
140
- console.print(
141
- f"[dim]If browser doesn't open, navigate to:[/dim]\n[link={auth_url}]{auth_url}[/link]"
142
- )
143
-
144
- # Try to open browser, handle errors gracefully
145
- try:
146
- webbrowser.open(auth_url)
147
- except Exception as e:
148
- console.print(f"[yellow]Warning: Could not open browser automatically: {e}[/yellow]")
149
- console.print(f"[dim]Please open the URL manually: {auth_url}[/dim]")
150
-
151
- # 5. Run local server to capture redirect
152
- try:
153
- AuthCallbackHandler.server = httpd_instance # type: ignore
154
- httpd_instance.handle_request() # type: ignore
155
- console.print("[dim]Local OAuth server shut down.[/dim]")
156
- except Exception as e:
157
- console.print(f"[bold red]Error running OAuth server: {e}[/bold red]")
158
- return
159
- finally:
160
- # Ensure server is closed
161
- if httpd_instance:
162
- try:
163
- httpd_instance.server_close()
164
- except Exception:
165
- pass
166
-
167
- if oauth_data["error"]:
168
- console.print(f"[bold red]Login failed: {oauth_data['error']}[/bold red]")
169
- return
170
-
171
- if not oauth_data["code"]:
172
- console.print("[bold red]Login failed: No authorization code received.[/bold red]")
173
- return
174
-
175
- # 6. Verify state (CSRF protection)
176
- if not oauth_data.get("state"):
177
- console.print(
178
- "[bold red]Login failed: State parameter missing in callback (possible CSRF attack)[/bold red]"
179
- )
180
- return
181
-
182
- if oauth_data["state"] != state:
183
- console.print(
184
- "[bold red]Login failed: State mismatch (possible CSRF attack)[/bold red]"
185
- )
186
- return
187
-
188
- # 7. Exchange code for tokens with retry logic
189
- console.print("[bold cyan]Exchanging authorization code for tokens...[/bold cyan]")
190
- try:
191
- token_data = _exchange_code_for_tokens_with_retry(
192
- oauth_data["code"], code_verifier, redirect_uri
193
- )
194
-
195
- access_token = token_data.get("access_token")
196
- refresh_token = token_data.get("refresh_token")
197
-
198
- if access_token and refresh_token:
199
- try:
200
- keyring.set_password(SERVICE_ID, "access_token", access_token)
201
- keyring.set_password(SERVICE_ID, "refresh_token", refresh_token)
202
- console.print(
203
- "[bold green]Login successful! Tokens saved securely.[/bold green]"
204
- )
205
- except keyring.errors.KeyringError as e:
206
- console.print(f"[bold red]Failed to save tokens to keyring: {e}[/bold red]")
207
- console.print("[yellow]Tokens were received but not saved.[/yellow]")
208
- else:
209
- console.print("[bold red]Login failed: No tokens received.[/bold red]")
210
-
211
- except httpx.TimeoutException:
212
- console.print(
213
- "[bold red]Token exchange failed: Request timed out. Please try again.[/bold red]"
214
- )
215
- except httpx.NetworkError as e:
216
- console.print(
217
- f"[bold red]Token exchange failed: Network error - {type(e).__name__}[/bold red]"
218
- )
219
- except httpx.HTTPStatusError as exc:
220
- error_detail = "Unknown error"
221
- try:
222
- if exc.response.content:
223
- content_type = exc.response.headers.get("content-type", "")
224
- if "application/json" in content_type:
225
- error_data = exc.response.json()
226
- error_detail = error_data.get("detail", str(error_data))
227
- except Exception:
228
- error_detail = exc.response.text[:200] if exc.response.text else "Unknown error"
229
-
230
- console.print(
231
- f"[bold red]Token exchange failed: {exc.response.status_code} - {error_detail}[/bold red]"
232
- )
233
- except ValueError as e:
234
- console.print(f"[bold red]Token exchange failed: {e}[/bold red]")
235
- except Exception as e:
236
- console.print(
237
- f"[bold red]Token exchange failed: Unexpected error - {type(e).__name__}[/bold red]"
238
- )
239
-
240
- except Exception as e:
241
- console.print(f"[bold red]Login failed: {type(e).__name__} - {e}[/bold red]")
242
- if httpd_instance:
243
- try:
244
- httpd_instance.server_close()
245
- except Exception:
246
- pass
92
+ # Removed old PKCE flow - now using Device Authorization Flow
247
93
 
248
94
 
249
95
  @auth.command()
@@ -0,0 +1,159 @@
1
+ """
2
+ Device Authorization Flow for Xenfra CLI.
3
+ Modern OAuth flow used by GitHub CLI, AWS CLI, Claude Code, etc.
4
+ """
5
+
6
+ import time
7
+ import webbrowser
8
+ from urllib.parse import urlencode
9
+
10
+ import click
11
+ import httpx
12
+ import keyring
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+
16
+ from ..utils.auth import API_BASE_URL, CLI_CLIENT_ID, HTTP_TIMEOUT, SERVICE_ID
17
+
18
+ console = Console()
19
+
20
+
21
+ def device_login():
22
+ """
23
+ Device Authorization Flow (OAuth 2.0 Device Grant).
24
+
25
+ Flow:
26
+ 1. CLI calls /auth/device/authorize to get device_code and user_code
27
+ 2. User visits https://www.xenfra.tech/activate and enters user_code
28
+ 3. CLI polls /auth/device/token until user authorizes
29
+ 4. CLI receives access_token and stores it
30
+ """
31
+ try:
32
+ # Step 1: Request device code
33
+ console.print("[cyan]Initiating device authorization...[/cyan]")
34
+
35
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
36
+ response = client.post(
37
+ f"{API_BASE_URL}/auth/device/authorize",
38
+ data={
39
+ "client_id": CLI_CLIENT_ID,
40
+ "scope": "openid profile",
41
+ },
42
+ )
43
+ response.raise_for_status()
44
+ device_data = response.json()
45
+
46
+ device_code = device_data["device_code"]
47
+ user_code = device_data["user_code"]
48
+ verification_uri = device_data["verification_uri"]
49
+ verification_uri_complete = device_data.get("verification_uri_complete")
50
+ expires_in = device_data["expires_in"]
51
+ interval = device_data.get("interval", 5)
52
+
53
+ # Step 2: Show user code and open browser
54
+ console.print()
55
+ console.print(
56
+ Panel.fit(
57
+ f"[bold white]{user_code}[/bold white]",
58
+ title="[bold green]Your Activation Code[/bold green]",
59
+ border_style="green",
60
+ )
61
+ )
62
+ console.print()
63
+ console.print(f"[bold]Visit:[/bold] [link]{verification_uri}[/link]")
64
+ console.print(f"[bold]Enter code:[/bold] [cyan]{user_code}[/cyan]")
65
+ console.print()
66
+
67
+ # Open browser automatically
68
+ try:
69
+ url_to_open = verification_uri_complete or verification_uri
70
+ webbrowser.open(url_to_open)
71
+ console.print("[dim]Opening browser...[/dim]")
72
+ except Exception:
73
+ console.print("[yellow]Could not open browser automatically. Please visit the URL above.[/yellow]")
74
+
75
+ # Step 3: Poll for authorization
76
+ console.print()
77
+ console.print("[cyan]Waiting for authorization...[/cyan]")
78
+ console.print("[dim](Press Ctrl+C to cancel)[/dim]")
79
+ console.print()
80
+
81
+ start_time = time.time()
82
+ poll_count = 0
83
+
84
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
85
+ while True:
86
+ # Check timeout
87
+ if time.time() - start_time > expires_in:
88
+ console.print("[bold red]✗ Authorization timed out. Please try again.[/bold red]")
89
+ return False
90
+
91
+ # Poll the token endpoint
92
+ try:
93
+ response = client.post(
94
+ f"{API_BASE_URL}/auth/device/token",
95
+ data={
96
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
97
+ "device_code": device_code,
98
+ "client_id": CLI_CLIENT_ID,
99
+ },
100
+ )
101
+
102
+ if response.status_code == 200:
103
+ # Success! User authorized
104
+ token_data = response.json()
105
+ access_token = token_data["access_token"]
106
+ refresh_token = token_data.get("refresh_token")
107
+
108
+ # Store tokens in keyring
109
+ try:
110
+ keyring.set_password(SERVICE_ID, "access_token", access_token)
111
+ if refresh_token:
112
+ keyring.set_password(SERVICE_ID, "refresh_token", refresh_token)
113
+ except keyring.errors.KeyringError as e:
114
+ console.print(f"[yellow]Warning: Could not save tokens to keyring: {e}[/yellow]")
115
+
116
+ console.print()
117
+ console.print("[bold green]✓ Successfully authenticated![/bold green]")
118
+ console.print()
119
+ return True
120
+
121
+ elif response.status_code == 400:
122
+ error_data = response.json()
123
+ error = error_data.get("error", "unknown_error")
124
+
125
+ if error == "authorization_pending":
126
+ # Still waiting for user to authorize
127
+ poll_count += 1
128
+ if poll_count % 6 == 0: # Every 30 seconds
129
+ console.print("[dim]Still waiting...[/dim]")
130
+ time.sleep(interval)
131
+ continue
132
+
133
+ elif error == "slow_down":
134
+ # We're polling too fast
135
+ interval += 5
136
+ time.sleep(interval)
137
+ continue
138
+
139
+ else:
140
+ # Other error
141
+ error_desc = error_data.get("error_description", error)
142
+ console.print(f"[bold red]✗ Authorization failed: {error_desc}[/bold red]")
143
+ return False
144
+
145
+ else:
146
+ console.print(f"[bold red]✗ Unexpected response: {response.status_code}[/bold red]")
147
+ return False
148
+
149
+ except httpx.HTTPError as e:
150
+ console.print(f"[bold red]✗ Network error: {e}[/bold red]")
151
+ return False
152
+
153
+ except KeyboardInterrupt:
154
+ console.print()
155
+ console.print("[yellow]Authorization cancelled.[/yellow]")
156
+ return False
157
+ except Exception as e:
158
+ console.print(f"[bold red]✗ Error: {e}[/bold red]")
159
+ return False
xenfra/main.py CHANGED
@@ -5,10 +5,8 @@ A modern, AI-powered CLI for deploying Python apps to DigitalOcean.
5
5
  """
6
6
 
7
7
  import os
8
- from pathlib import Path
9
8
 
10
9
  import click
11
- from dotenv import load_dotenv
12
10
  from rich.console import Console
13
11
 
14
12
  from .commands.auth import auth
@@ -19,14 +17,12 @@ from .commands.security_cmd import security
19
17
 
20
18
  console = Console()
21
19
 
22
- # Load .env file from project root (searches parent directories)
23
- # This allows CLI to use XENFRA_API_URL and other vars from .env
24
- load_dotenv(dotenv_path=Path.cwd() / ".env", override=False)
25
- load_dotenv(override=False) # Also check current directory and parents
20
+ # Production-ready: API URL is hardcoded as https://api.xenfra.tech
21
+ # No configuration needed - works out of the box after pip install
26
22
 
27
23
 
28
24
  @click.group()
29
- @click.version_option(version="0.2.3")
25
+ @click.version_option(version="0.2.9")
30
26
  def cli():
31
27
  """
32
28
  Xenfra CLI: Deploy Python apps to DigitalOcean with zero configuration.
xenfra/utils/security.py CHANGED
@@ -44,22 +44,15 @@ class SecurityConfig:
44
44
 
45
45
  def __init__(self):
46
46
  """Initialize security configuration from environment."""
47
- # Environment detection - Solution 3
48
- self.environment = os.getenv("XENFRA_ENV", "development").lower()
49
-
50
- # Security settings (can be overridden by environment variables)
51
- self.enforce_https = os.getenv("XENFRA_ENFORCE_HTTPS", "false").lower() == "true"
52
- self.enforce_whitelist = os.getenv("XENFRA_ENFORCE_WHITELIST", "false").lower() == "true"
53
- self.enable_cert_pinning = (
54
- os.getenv("XENFRA_ENABLE_CERT_PINNING", "false").lower() == "true"
55
- )
56
- self.warn_on_http = os.getenv("XENFRA_WARN_ON_HTTP", "true").lower() == "true"
47
+ # PRODUCTION-ONLY: Default to production settings
48
+ # Environment variable only used for self-hosted instances
49
+ self.environment = "production"
57
50
 
58
- # Auto-enable strict security in production
59
- if self.environment == "production":
60
- self.enforce_https = True
61
- self.enforce_whitelist = True
62
- self.enable_cert_pinning = True
51
+ # Security settings - ALWAYS enforced for production safety
52
+ self.enforce_https = True # Always require HTTPS
53
+ self.enforce_whitelist = False # Allow self-hosted instances
54
+ self.enable_cert_pinning = False # Disabled (see future-enhancements.md #3)
55
+ self.warn_on_http = True # Always warn on HTTP
63
56
 
64
57
  def is_production(self) -> bool:
65
58
  """Check if running in production environment."""
@@ -243,25 +236,19 @@ def validate_and_get_api_url(url: str = None) -> str:
243
236
  Comprehensive API URL validation (combines all 4 solutions).
244
237
 
245
238
  Args:
246
- url: Optional URL override (defaults to XENFRA_API_URL env var)
239
+ url: Optional URL override (only for self-hosted instances)
247
240
 
248
241
  Returns:
249
- Validated API URL
242
+ Validated API URL (defaults to https://api.xenfra.tech)
250
243
 
251
244
  Raises:
252
245
  ValueError: If URL fails validation
253
246
  click.Abort: If user cancels security prompts
254
247
  """
255
- # Get URL from parameter or environment
248
+ # PRODUCTION DEFAULT: Use hardcoded production URL
249
+ # Only check environment variable for self-hosted overrides
256
250
  if url is None:
257
- url = os.getenv("XENFRA_API_URL")
258
-
259
- # Use production URL in production environment
260
- if url is None and security_config.is_production():
261
- url = PRODUCTION_API_URL
262
- # Use localhost in development
263
- elif url is None:
264
- url = "http://localhost:8000"
251
+ url = os.getenv("XENFRA_API_URL", PRODUCTION_API_URL)
265
252
 
266
253
  try:
267
254
  # Solution 1: Validate URL format
@@ -316,41 +303,34 @@ def display_security_info():
316
303
 
317
304
  # Environment variable documentation
318
305
  """
319
- Security can be configured via environment variables:
320
-
321
- XENFRA_ENV=production|staging|development
322
- - Controls default security settings
323
- - production: All security features enabled
324
- - development: Permissive mode (localhost allowed)
306
+ PRODUCTION-FIRST DESIGN:
307
+ The CLI defaults to production (api.xenfra.tech) with HTTPS enforcement.
308
+ No configuration needed for normal users.
325
309
 
326
- XENFRA_ENFORCE_HTTPS=true|false
327
- - Require HTTPS for all connections (except localhost)
328
- - Default: false (dev), true (production)
329
-
330
- XENFRA_ENFORCE_WHITELIST=true|false
331
- - Block connections to non-whitelisted domains
332
- - Default: false (dev), true (production)
310
+ Environment variables (for developers/self-hosted only):
333
311
 
334
- XENFRA_ENABLE_CERT_PINNING=true|false
335
- - Enable certificate pinning for production domains
336
- - Default: false (dev), true (production)
312
+ XENFRA_ENV=development
313
+ - Enables local development mode
314
+ - Allows HTTP, relaxes security
315
+ - Default: production (safe by default)
337
316
 
338
- XENFRA_WARN_ON_HTTP=true|false
339
- - Show warning when using HTTP (non-localhost)
340
- - Default: true
317
+ XENFRA_API_URL=https://your-instance.com
318
+ - Override API URL for self-hosted instances
319
+ - Default: https://api.xenfra.tech
341
320
 
342
- XENFRA_API_URL=https://api.example.com
343
- - Override default API URL
344
- - Subject to all security validations
321
+ XENFRA_ENFORCE_HTTPS=true|false
322
+ - Require HTTPS for all connections
323
+ - Default: true (production), false (development)
345
324
 
346
325
  Example usage:
347
326
 
348
- # Development (permissive):
349
- XENFRA_API_URL=http://localhost:8000 xenfra login
327
+ # Production users (zero config):
328
+ xenfra auth login
329
+ xenfra deploy
350
330
 
351
- # Self-hosted instance (disable whitelist):
352
- XENFRA_API_URL=https://xenfra.mycompany.com XENFRA_ENFORCE_WHITELIST=false xenfra login
331
+ # Local development:
332
+ XENFRA_ENV=development xenfra auth login
353
333
 
354
- # Production (strict):
355
- XENFRA_ENV=production XENFRA_API_URL=https://api.xenfra.tech xenfra login
334
+ # Self-hosted instance:
335
+ XENFRA_API_URL=https://xenfra.mycompany.com xenfra login
356
336
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: xenfra
3
- Version: 0.2.7
3
+ Version: 0.2.9
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,18 +1,19 @@
1
1
  xenfra/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  xenfra/commands/__init__.py,sha256=bdugTOErbWUhDvnwFl17KkGPPV7gtmDkSOzhF_NEHX0,40
3
- xenfra/commands/auth.py,sha256=W7ncG9ombHRKpjxKEZov5Fj3Tlr8Cx0mbze5nrPedlw,10175
3
+ xenfra/commands/auth.py,sha256=vFsvoIzc_kea__2wA62YioxOURLorFIqiVZoqKaKRbk,3972
4
+ xenfra/commands/auth_device.py,sha256=gUUBiOnszUCiUkFdIH1DoIzEnV4AS38MRy6WwAGo_4Q,6205
4
5
  xenfra/commands/deployments.py,sha256=-185BevHVrUT-LAU2k_uZNpKJPCcwpCDEHOFPfD0Wmw,19348
5
6
  xenfra/commands/intelligence.py,sha256=w8GxwGu63KQ5fwhPpTNTDeW1Xg5g3aFzzIBuP_CeRQo,13541
6
7
  xenfra/commands/projects.py,sha256=O2tG--iDWN5oCcHOv1jp88kl9bAK61oGRCLJ60M0b7E,6492
7
8
  xenfra/commands/security_cmd.py,sha256=MJxbjQksKrtRn21FSAhTY3ESn_S_tUCGfdNRWL7kNsc,7094
8
- xenfra/main.py,sha256=KLIqdvMpo2ahoz_vnoxq9yPwOJhnyRJKQ4pDgr4FovY,2110
9
+ xenfra/main.py,sha256=kLZWK-aAw3HC7uOp85WKSsNDiyy6XifWwGMOAbTQPaU,1926
9
10
  xenfra/utils/__init__.py,sha256=57o8j7Tibrhyid84zTFLHjFmRP5sCnNbtLEfpRqIpMk,42
10
11
  xenfra/utils/auth.py,sha256=oDxDiIWC9851fu_gL-7TVJ60uJT3sZ_DvMIy69SUAEM,8308
11
12
  xenfra/utils/codebase.py,sha256=vx-1pMpnefPJ_Xy1UoH7wgHJ2c5ZAsVX1g1IXAfkI28,4018
12
13
  xenfra/utils/config.py,sha256=6A6WAggaH2Rco4RJydALxcKteOzXLCKDV0ZxjHhAJHk,11584
13
- xenfra/utils/security.py,sha256=IR_Hzgfc4KeT-qr2LVBxSvBTO4AP4MCkIlU47_yKN_0,11947
14
+ xenfra/utils/security.py,sha256=V0CqA47ZYt-8AesWb7FPRzzygqEY_g2WF1Duvs5BZ_Y,11143
14
15
  xenfra/utils/validation.py,sha256=6mGC5CqAbx-CBp06omWLBpKjnEWXsEzlYWq71wjDeX8,6678
15
- xenfra-0.2.7.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
16
- xenfra-0.2.7.dist-info/entry_points.txt,sha256=a_2cGhYK__X6eW05Ba8uB6RIM_61c2sHtXsPY8N0mic,45
17
- xenfra-0.2.7.dist-info/METADATA,sha256=6BeWMSpOmcy-enLutVCeq8XOR0MfuRJRaz4gEbSD_bA,3751
18
- xenfra-0.2.7.dist-info/RECORD,,
16
+ xenfra-0.2.9.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
17
+ xenfra-0.2.9.dist-info/entry_points.txt,sha256=a_2cGhYK__X6eW05Ba8uB6RIM_61c2sHtXsPY8N0mic,45
18
+ xenfra-0.2.9.dist-info/METADATA,sha256=-SyUFzDdmncdgGvbOHdgepZM6IDYQIpziPhZs0v8FzE,3751
19
+ xenfra-0.2.9.dist-info/RECORD,,
File without changes