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.
- {xenfra-0.2.7 → xenfra-0.2.9}/PKG-INFO +1 -1
- {xenfra-0.2.7 → xenfra-0.2.9}/pyproject.toml +2 -2
- xenfra-0.2.9/src/xenfra/commands/auth.py +137 -0
- xenfra-0.2.9/src/xenfra/commands/auth_device.py +159 -0
- {xenfra-0.2.7 → xenfra-0.2.9}/src/xenfra/main.py +3 -7
- {xenfra-0.2.7 → xenfra-0.2.9}/src/xenfra/utils/security.py +34 -54
- xenfra-0.2.7/src/xenfra/commands/auth.py +0 -291
- {xenfra-0.2.7 → xenfra-0.2.9}/README.md +0 -0
- {xenfra-0.2.7 → xenfra-0.2.9}/src/xenfra/__init__.py +0 -0
- {xenfra-0.2.7 → xenfra-0.2.9}/src/xenfra/commands/__init__.py +0 -0
- {xenfra-0.2.7 → xenfra-0.2.9}/src/xenfra/commands/deployments.py +0 -0
- {xenfra-0.2.7 → xenfra-0.2.9}/src/xenfra/commands/intelligence.py +0 -0
- {xenfra-0.2.7 → xenfra-0.2.9}/src/xenfra/commands/projects.py +0 -0
- {xenfra-0.2.7 → xenfra-0.2.9}/src/xenfra/commands/security_cmd.py +0 -0
- {xenfra-0.2.7 → xenfra-0.2.9}/src/xenfra/utils/__init__.py +0 -0
- {xenfra-0.2.7 → xenfra-0.2.9}/src/xenfra/utils/auth.py +0 -0
- {xenfra-0.2.7 → xenfra-0.2.9}/src/xenfra/utils/codebase.py +0 -0
- {xenfra-0.2.7 → xenfra-0.2.9}/src/xenfra/utils/config.py +0 -0
- {xenfra-0.2.7 → xenfra-0.2.9}/src/xenfra/utils/validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xenfra"
|
|
3
|
-
version = "0.2.
|
|
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
|
-
#
|
|
23
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
48
|
-
|
|
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
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 (
|
|
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
|
-
#
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
-
|
|
336
|
-
-
|
|
312
|
+
XENFRA_ENV=development
|
|
313
|
+
- Enables local development mode
|
|
314
|
+
- Allows HTTP, relaxes security
|
|
315
|
+
- Default: production (safe by default)
|
|
337
316
|
|
|
338
|
-
|
|
339
|
-
-
|
|
340
|
-
- Default:
|
|
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
|
-
|
|
343
|
-
-
|
|
344
|
-
-
|
|
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
|
-
#
|
|
349
|
-
|
|
327
|
+
# Production users (zero config):
|
|
328
|
+
xenfra auth login
|
|
329
|
+
xenfra deploy
|
|
350
330
|
|
|
351
|
-
#
|
|
352
|
-
|
|
331
|
+
# Local development:
|
|
332
|
+
XENFRA_ENV=development xenfra auth login
|
|
353
333
|
|
|
354
|
-
#
|
|
355
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|