xenfra 0.2.7__tar.gz → 0.2.9__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.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,6 +1,6 @@
1
1
  [project]
2
2
  name = "xenfra"
3
- version = "0.2.7"
3
+ version = "0.2.9"
4
4
  description = "A 'Zen Mode' infrastructure engine for Python developers."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -51,4 +51,4 @@ xenfra = "xenfra.main:main"
51
51
 
52
52
  [build-system]
53
53
  requires = ["uv_build>=0.9.18,<0.10.0"]
54
- build-backend = "uv_build"
54
+ build-backend = "uv_build"
@@ -0,0 +1,137 @@
1
+ """
2
+ Authentication commands for Xenfra CLI.
3
+ """
4
+
5
+ import base64
6
+ import hashlib
7
+ import secrets
8
+ import urllib.parse
9
+ import webbrowser
10
+ from http.server import HTTPServer
11
+
12
+ import click
13
+ import httpx
14
+ import keyring
15
+ from rich.console import Console
16
+ from tenacity import (
17
+ retry,
18
+ retry_if_exception_type,
19
+ stop_after_attempt,
20
+ wait_exponential,
21
+ )
22
+
23
+ from ..utils.auth import (
24
+ API_BASE_URL,
25
+ CLI_CLIENT_ID,
26
+ CLI_LOCAL_SERVER_END_PORT,
27
+ CLI_LOCAL_SERVER_START_PORT,
28
+ CLI_REDIRECT_PATH,
29
+ SERVICE_ID,
30
+ AuthCallbackHandler,
31
+ clear_tokens,
32
+ get_auth_token,
33
+ )
34
+
35
+ console = Console()
36
+
37
+ # HTTP request timeout (30 seconds)
38
+ HTTP_TIMEOUT = 30.0
39
+
40
+
41
+ @click.group()
42
+ def auth():
43
+ """Authentication commands (login, logout, whoami)."""
44
+ pass
45
+
46
+
47
+ @retry(
48
+ stop=stop_after_attempt(3),
49
+ wait=wait_exponential(multiplier=1, min=2, max=10),
50
+ retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
51
+ reraise=True,
52
+ )
53
+ def _exchange_code_for_tokens_with_retry(code: str, code_verifier: str, redirect_uri: str) -> dict:
54
+ """
55
+ Exchange authorization code for tokens with retry logic.
56
+
57
+ Returns token data dictionary.
58
+ """
59
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
60
+ response = client.post(
61
+ f"{API_BASE_URL}/auth/token",
62
+ data={
63
+ "grant_type": "authorization_code",
64
+ "client_id": CLI_CLIENT_ID,
65
+ "code": code,
66
+ "code_verifier": code_verifier,
67
+ "redirect_uri": redirect_uri,
68
+ },
69
+ headers={"Accept": "application/json"},
70
+ )
71
+ response.raise_for_status()
72
+
73
+ # Safe JSON parsing with content-type check
74
+ content_type = response.headers.get("content-type", "")
75
+ if "application/json" not in content_type:
76
+ raise ValueError(f"Expected JSON response, got {content_type}")
77
+
78
+ try:
79
+ token_data = response.json()
80
+ except (ValueError, TypeError) as e:
81
+ raise ValueError(f"Failed to parse JSON response: {e}")
82
+
83
+ return token_data
84
+
85
+
86
+ @auth.command()
87
+ def login():
88
+ """Login to Xenfra using Device Authorization Flow (like GitHub CLI, Claude Code)."""
89
+ from .auth_device import device_login
90
+ device_login()
91
+
92
+ # Removed old PKCE flow - now using Device Authorization Flow
93
+
94
+
95
+ @auth.command()
96
+ def logout():
97
+ """Logout and clear stored tokens."""
98
+ try:
99
+ clear_tokens()
100
+ console.print("[bold green]Logged out successfully.[/bold green]")
101
+ except Exception as e:
102
+ console.print(f"[yellow]Warning: Error during logout: {e}[/yellow]")
103
+ console.print("[dim]Tokens may still be stored in keyring.[/dim]")
104
+
105
+
106
+ @auth.command()
107
+ @click.option("--token", is_flag=True, help="Show access token")
108
+ def whoami(token):
109
+ """Show current authenticated user."""
110
+ access_token = get_auth_token()
111
+
112
+ if not access_token:
113
+ console.print("[bold red]Not logged in. Run 'xenfra login' first.[/bold red]")
114
+ return
115
+
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
+ )
129
+
130
+ 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')}")
133
+
134
+ if token:
135
+ console.print(f"\n[dim]Access Token:[/dim]\n{access_token}")
136
+ except Exception as e:
137
+ console.print(f"[bold red]Failed to decode token: {e}[/bold red]")
@@ -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
@@ -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.
@@ -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,291 +0,0 @@
1
- """
2
- Authentication commands for Xenfra CLI.
3
- """
4
-
5
- import base64
6
- import hashlib
7
- import secrets
8
- import urllib.parse
9
- import webbrowser
10
- from http.server import HTTPServer
11
-
12
- import click
13
- import httpx
14
- import keyring
15
- from rich.console import Console
16
- from tenacity import (
17
- retry,
18
- retry_if_exception_type,
19
- stop_after_attempt,
20
- wait_exponential,
21
- )
22
-
23
- from ..utils.auth import (
24
- API_BASE_URL,
25
- CLI_CLIENT_ID,
26
- CLI_LOCAL_SERVER_END_PORT,
27
- CLI_LOCAL_SERVER_START_PORT,
28
- CLI_REDIRECT_PATH,
29
- SERVICE_ID,
30
- AuthCallbackHandler,
31
- clear_tokens,
32
- get_auth_token,
33
- )
34
-
35
- console = Console()
36
-
37
- # HTTP request timeout (30 seconds)
38
- HTTP_TIMEOUT = 30.0
39
-
40
-
41
- @click.group()
42
- def auth():
43
- """Authentication commands (login, logout, whoami)."""
44
- pass
45
-
46
-
47
- @retry(
48
- stop=stop_after_attempt(3),
49
- wait=wait_exponential(multiplier=1, min=2, max=10),
50
- retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
51
- reraise=True,
52
- )
53
- def _exchange_code_for_tokens_with_retry(code: str, code_verifier: str, redirect_uri: str) -> dict:
54
- """
55
- Exchange authorization code for tokens with retry logic.
56
-
57
- Returns token data dictionary.
58
- """
59
- with httpx.Client(timeout=HTTP_TIMEOUT) as client:
60
- response = client.post(
61
- f"{API_BASE_URL}/auth/token",
62
- data={
63
- "grant_type": "authorization_code",
64
- "client_id": CLI_CLIENT_ID,
65
- "code": code,
66
- "code_verifier": code_verifier,
67
- "redirect_uri": redirect_uri,
68
- },
69
- headers={"Accept": "application/json"},
70
- )
71
- response.raise_for_status()
72
-
73
- # Safe JSON parsing with content-type check
74
- content_type = response.headers.get("content-type", "")
75
- if "application/json" not in content_type:
76
- raise ValueError(f"Expected JSON response, got {content_type}")
77
-
78
- try:
79
- token_data = response.json()
80
- except (ValueError, TypeError) as e:
81
- raise ValueError(f"Failed to parse JSON response: {e}")
82
-
83
- return token_data
84
-
85
-
86
- @auth.command()
87
- def login():
88
- """Login to Xenfra using OAuth2 PKCE flow."""
89
- global oauth_data
90
- oauth_data = {"code": None, "state": None, "error": None}
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
247
-
248
-
249
- @auth.command()
250
- def logout():
251
- """Logout and clear stored tokens."""
252
- try:
253
- clear_tokens()
254
- console.print("[bold green]Logged out successfully.[/bold green]")
255
- except Exception as e:
256
- console.print(f"[yellow]Warning: Error during logout: {e}[/yellow]")
257
- console.print("[dim]Tokens may still be stored in keyring.[/dim]")
258
-
259
-
260
- @auth.command()
261
- @click.option("--token", is_flag=True, help="Show access token")
262
- def whoami(token):
263
- """Show current authenticated user."""
264
- access_token = get_auth_token()
265
-
266
- if not access_token:
267
- console.print("[bold red]Not logged in. Run 'xenfra login' first.[/bold red]")
268
- return
269
-
270
- try:
271
- from jose import jwt
272
-
273
- # For display purposes only, in a CLI context where the token has just
274
- # been retrieved from a secure source (keyring), we can disable
275
- # signature verification.
276
- #
277
- # SECURITY BEST PRACTICE: In a real application, especially a server,
278
- # you would fetch the public key from the SSO's JWKS endpoint and
279
- # fully verify the token's signature to ensure its integrity.
280
- claims = jwt.decode(
281
- access_token, options={"verify_signature": False} # OK for local display
282
- )
283
-
284
- console.print("[bold green]Logged in as:[/bold green]")
285
- console.print(f" User ID: {claims.get('sub')}")
286
- console.print(f" Email: {claims.get('email', 'N/A')}")
287
-
288
- if token:
289
- console.print(f"\n[dim]Access Token:[/dim]\n{access_token}")
290
- except Exception as e:
291
- console.print(f"[bold red]Failed to decode token: {e}[/bold red]")
File without changes
File without changes
File without changes