xenfra 0.2.8__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
@@ -22,7 +22,7 @@ console = Console()
22
22
 
23
23
 
24
24
  @click.group()
25
- @click.version_option(version="0.2.5")
25
+ @click.version_option(version="0.2.9")
26
26
  def cli():
27
27
  """
28
28
  Xenfra CLI: Deploy Python apps to DigitalOcean with zero configuration.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: xenfra
3
- Version: 0.2.8
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=GJvPsEDifQok1uX_PY8WH_p2eq9OZ-lCiapoExIO0HU,1926
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
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.8.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
16
- xenfra-0.2.8.dist-info/entry_points.txt,sha256=a_2cGhYK__X6eW05Ba8uB6RIM_61c2sHtXsPY8N0mic,45
17
- xenfra-0.2.8.dist-info/METADATA,sha256=IGNwf_5QKQpwjo2zlmKeAlLwXtI4Y0s97Ox1dZ5GOsI,3751
18
- xenfra-0.2.8.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