xenfra 0.2.9__py3-none-any.whl → 0.3.0__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/utils/auth.py CHANGED
@@ -1,226 +1,243 @@
1
- """
2
- Authentication utilities for Xenfra CLI.
3
- Handles OAuth2 PKCE flow and token management.
4
- """
5
-
6
- from http.server import BaseHTTPRequestHandler, HTTPServer
7
- from urllib.parse import parse_qs, urlparse
8
-
9
- import httpx
10
- import keyring
11
- from rich.console import Console
12
- from tenacity import (
13
- retry,
14
- retry_if_exception_type,
15
- stop_after_attempt,
16
- wait_exponential,
17
- )
18
-
19
- from .security import validate_and_get_api_url
20
-
21
- console = Console()
22
-
23
- # Get validated API URL (includes all security checks)
24
- API_BASE_URL = validate_and_get_api_url()
25
- SERVICE_ID = "xenfra"
26
-
27
- # CLI OAuth2 Configuration
28
- CLI_CLIENT_ID = "xenfra-cli"
29
- CLI_REDIRECT_PATH = "/auth/callback"
30
- CLI_LOCAL_SERVER_START_PORT = 8001
31
- CLI_LOCAL_SERVER_END_PORT = 8005
32
-
33
- # HTTP request timeout (30 seconds)
34
- HTTP_TIMEOUT = 30.0
35
-
36
- # Global storage for OAuth callback data
37
- oauth_data = {"code": None, "state": None, "error": None}
38
-
39
-
40
- class AuthCallbackHandler(BaseHTTPRequestHandler):
41
- """HTTP handler for OAuth redirect callback."""
42
-
43
- def do_GET(self):
44
- global oauth_data
45
- self.send_response(200)
46
- self.send_header("Content-type", "text/html")
47
- self.end_headers()
48
-
49
- query_params = parse_qs(urlparse(self.path).query)
50
-
51
- if "code" in query_params:
52
- oauth_data["code"] = query_params["code"][0]
53
- oauth_data["state"] = query_params["state"][0] if "state" in query_params else None
54
- self.wfile.write(
55
- b"<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>"
56
- )
57
- elif "error" in query_params:
58
- oauth_data["error"] = query_params["error"][0]
59
- self.wfile.write(
60
- f"<html><body><h1>Authentication failed!</h1><p>Error: {oauth_data['error']}</p></body></html>".encode()
61
- )
62
- else:
63
- self.wfile.write(
64
- b"<html><body><h1>Authentication callback received.</h1><p>Waiting for code...</p></body></html>"
65
- )
66
-
67
- # Shut down the server after processing
68
- self.server.shutdown() # type: ignore
69
-
70
-
71
- def run_local_oauth_server(port: int, redirect_path: str):
72
- """Start a local HTTP server to capture the OAuth redirect."""
73
- server_address = ("127.0.0.1", port)
74
- httpd = HTTPServer(server_address, AuthCallbackHandler)
75
- httpd.timeout = 30 # seconds
76
- console.print(
77
- f"[dim]Listening for OAuth redirect on http://localhost:{port}{redirect_path}...[/dim]"
78
- )
79
-
80
- # Store the server instance in the handler for shutdown
81
- AuthCallbackHandler.server = httpd # type: ignore
82
-
83
- # Handle a single request (blocking call)
84
- httpd.handle_request()
85
- console.print("[dim]Local OAuth server shut down.[/dim]")
86
-
87
-
88
- @retry(
89
- stop=stop_after_attempt(3),
90
- wait=wait_exponential(multiplier=1, min=2, max=10),
91
- retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
92
- reraise=True,
93
- )
94
- def _refresh_token_with_retry(refresh_token: str) -> dict:
95
- """
96
- Refresh access token with retry logic.
97
-
98
- Returns token data dictionary.
99
- """
100
- with httpx.Client(timeout=HTTP_TIMEOUT) as client:
101
- response = client.post(
102
- f"{API_BASE_URL}/auth/refresh",
103
- data={"refresh_token": refresh_token, "client_id": CLI_CLIENT_ID},
104
- headers={"Accept": "application/json"},
105
- )
106
- response.raise_for_status()
107
-
108
- # Safe JSON parsing with content-type check
109
- content_type = response.headers.get("content-type", "")
110
- if "application/json" not in content_type:
111
- raise ValueError(f"Expected JSON response, got {content_type}")
112
-
113
- try:
114
- token_data = response.json()
115
- except (ValueError, TypeError) as e:
116
- raise ValueError(f"Failed to parse JSON response: {e}")
117
-
118
- return token_data
119
-
120
-
121
- def get_auth_token() -> str | None:
122
- """
123
- Retrieve a valid access token, refreshing it if necessary.
124
-
125
- Returns:
126
- Valid access token or None if not authenticated
127
- """
128
- try:
129
- access_token = keyring.get_password(SERVICE_ID, "access_token")
130
- refresh_token = keyring.get_password(SERVICE_ID, "refresh_token")
131
- except keyring.errors.KeyringError as e:
132
- console.print(f"[yellow]Warning: Could not access keyring: {e}[/yellow]")
133
- return None
134
-
135
- if not access_token:
136
- return None
137
-
138
- # Check if access token is expired
139
- try:
140
- from jose import JWTError, jwt
141
-
142
- # Decode without verifying signature to check expiration
143
- claims = jwt.decode(access_token, options={"verify_signature": False, "verify_exp": True})
144
- except JWTError:
145
- claims = None
146
- except Exception as e:
147
- console.print(f"[dim]Error decoding access token: {e}[/dim]")
148
- claims = None
149
-
150
- # Refresh token if expired
151
- if not claims and refresh_token:
152
- console.print("[dim]Access token expired. Attempting to refresh...[/dim]")
153
- try:
154
- token_data = _refresh_token_with_retry(refresh_token)
155
- new_access_token = token_data.get("access_token")
156
- new_refresh_token = token_data.get("refresh_token")
157
-
158
- if new_access_token:
159
- try:
160
- keyring.set_password(SERVICE_ID, "access_token", new_access_token)
161
- if new_refresh_token:
162
- keyring.set_password(SERVICE_ID, "refresh_token", new_refresh_token)
163
- console.print("[bold green]Token refreshed successfully.[/bold green]")
164
- return new_access_token
165
- except keyring.errors.KeyringError as e:
166
- console.print(
167
- f"[yellow]Warning: Could not save refreshed token to keyring: {e}[/yellow]"
168
- )
169
- # Return the token anyway, but warn user
170
- return new_access_token
171
- else:
172
- console.print("[bold red]Failed to get new access token.[/bold red]")
173
- return None
174
-
175
- except httpx.TimeoutException:
176
- console.print("[bold red]Token refresh failed: Request timed out.[/bold red]")
177
- return None
178
- except httpx.NetworkError:
179
- console.print("[bold red]Token refresh failed: Network error.[/bold red]")
180
- return None
181
- except httpx.HTTPStatusError as exc:
182
- if exc.response.status_code == 400:
183
- console.print("[bold red]Refresh token expired. Please log in again.[/bold red]")
184
- else:
185
- error_detail = "Unknown error"
186
- try:
187
- if exc.response.content:
188
- content_type = exc.response.headers.get("content-type", "")
189
- if "application/json" in content_type:
190
- error_data = exc.response.json()
191
- error_detail = error_data.get("detail", str(error_data))
192
- except Exception:
193
- error_detail = exc.response.text[:200] if exc.response.text else "Unknown error"
194
-
195
- console.print(
196
- f"[bold red]Token refresh failed: {exc.response.status_code} - {error_detail}[/bold red]"
197
- )
198
-
199
- # Clear tokens on refresh failure
200
- try:
201
- keyring.delete_password(SERVICE_ID, "access_token")
202
- keyring.delete_password(SERVICE_ID, "refresh_token")
203
- except keyring.errors.KeyringError:
204
- pass # Ignore errors when clearing
205
- return None
206
- except ValueError as e:
207
- console.print(f"[bold red]Token refresh failed: {e}[/bold red]")
208
- return None
209
- except Exception as e:
210
- console.print(
211
- f"[bold red]Token refresh failed: Unexpected error - {type(e).__name__}[/bold red]"
212
- )
213
- return None
214
-
215
- return access_token
216
-
217
-
218
- def clear_tokens():
219
- """Clear stored access and refresh tokens."""
220
- try:
221
- keyring.delete_password(SERVICE_ID, "access_token")
222
- keyring.delete_password(SERVICE_ID, "refresh_token")
223
- except keyring.errors.PasswordDeleteError:
224
- pass # Tokens already cleared
225
- except keyring.errors.KeyringError as e:
226
- console.print(f"[yellow]Warning: Could not clear tokens from keyring: {e}[/yellow]")
1
+ """
2
+ Authentication utilities for Xenfra CLI.
3
+ Handles OAuth2 PKCE flow and token management.
4
+ """
5
+
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+ from urllib.parse import parse_qs, urlparse
8
+
9
+ import httpx
10
+ import keyring
11
+ from rich.console import Console
12
+ from tenacity import (
13
+ retry,
14
+ retry_if_exception_type,
15
+ stop_after_attempt,
16
+ wait_exponential,
17
+ )
18
+
19
+ from .security import validate_and_get_api_url
20
+
21
+ console = Console()
22
+
23
+ # Get validated API URL (includes all security checks)
24
+ API_BASE_URL = validate_and_get_api_url()
25
+ SERVICE_ID = "xenfra"
26
+
27
+ # CLI OAuth2 Configuration
28
+ CLI_CLIENT_ID = "xenfra-cli"
29
+ CLI_REDIRECT_PATH = "/auth/callback"
30
+ CLI_LOCAL_SERVER_START_PORT = 8001
31
+ CLI_LOCAL_SERVER_END_PORT = 8005
32
+
33
+ # HTTP request timeout (30 seconds)
34
+ HTTP_TIMEOUT = 30.0
35
+
36
+ # Global storage for OAuth callback data
37
+ oauth_data = {"code": None, "state": None, "error": None}
38
+
39
+
40
+ class AuthCallbackHandler(BaseHTTPRequestHandler):
41
+ """HTTP handler for OAuth redirect callback."""
42
+
43
+ def do_GET(self):
44
+ global oauth_data
45
+ self.send_response(200)
46
+ self.send_header("Content-type", "text/html")
47
+ self.end_headers()
48
+
49
+ query_params = parse_qs(urlparse(self.path).query)
50
+
51
+ if "code" in query_params:
52
+ oauth_data["code"] = query_params["code"][0]
53
+ oauth_data["state"] = query_params["state"][0] if "state" in query_params else None
54
+ self.wfile.write(
55
+ b"<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>"
56
+ )
57
+ elif "error" in query_params:
58
+ oauth_data["error"] = query_params["error"][0]
59
+ self.wfile.write(
60
+ f"<html><body><h1>Authentication failed!</h1><p>Error: {oauth_data['error']}</p></body></html>".encode()
61
+ )
62
+ else:
63
+ self.wfile.write(
64
+ b"<html><body><h1>Authentication callback received.</h1><p>Waiting for code...</p></body></html>"
65
+ )
66
+
67
+ # Shut down the server after processing
68
+ self.server.shutdown() # type: ignore
69
+
70
+
71
+ def run_local_oauth_server(port: int, redirect_path: str):
72
+ """Start a local HTTP server to capture the OAuth redirect."""
73
+ server_address = ("127.0.0.1", port)
74
+ httpd = HTTPServer(server_address, AuthCallbackHandler)
75
+ httpd.timeout = 30 # seconds
76
+ console.print(
77
+ f"[dim]Listening for OAuth redirect on http://localhost:{port}{redirect_path}...[/dim]"
78
+ )
79
+
80
+ # Store the server instance in the handler for shutdown
81
+ AuthCallbackHandler.server = httpd # type: ignore
82
+
83
+ # Handle a single request (blocking call)
84
+ httpd.handle_request()
85
+ console.print("[dim]Local OAuth server shut down.[/dim]")
86
+
87
+
88
+ @retry(
89
+ stop=stop_after_attempt(3),
90
+ wait=wait_exponential(multiplier=1, min=2, max=10),
91
+ retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
92
+ reraise=True,
93
+ )
94
+ def _refresh_token_with_retry(refresh_token: str) -> dict:
95
+ """
96
+ Refresh access token with retry logic.
97
+
98
+ Returns token data dictionary.
99
+ """
100
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
101
+ response = client.post(
102
+ f"{API_BASE_URL}/auth/refresh",
103
+ data={"refresh_token": refresh_token, "client_id": CLI_CLIENT_ID},
104
+ headers={"Accept": "application/json"},
105
+ )
106
+ response.raise_for_status()
107
+
108
+ # Safe JSON parsing with content-type check
109
+ content_type = response.headers.get("content-type", "")
110
+ if "application/json" not in content_type:
111
+ raise ValueError(f"Expected JSON response, got {content_type}")
112
+
113
+ try:
114
+ token_data = response.json()
115
+ except (ValueError, TypeError) as e:
116
+ raise ValueError(f"Failed to parse JSON response: {e}")
117
+
118
+ return token_data
119
+
120
+
121
+ def get_auth_token() -> str | None:
122
+ """
123
+ Retrieve a valid access token, refreshing it if necessary.
124
+
125
+ Returns:
126
+ Valid access token or None if not authenticated
127
+ """
128
+ try:
129
+ access_token = keyring.get_password(SERVICE_ID, "access_token")
130
+ refresh_token = keyring.get_password(SERVICE_ID, "refresh_token")
131
+ except keyring.errors.KeyringError as e:
132
+ console.print(f"[yellow]Warning: Could not access keyring: {e}[/yellow]")
133
+ return None
134
+
135
+ if not access_token:
136
+ return None
137
+
138
+ # Check if access token is expired
139
+ # Manually decode JWT payload to check expiration without verifying signature
140
+ try:
141
+ import base64
142
+ import json
143
+
144
+ # JWT format: header.payload.signature
145
+ parts = access_token.split(".")
146
+ if len(parts) != 3:
147
+ claims = None
148
+ else:
149
+ # Decode payload (second part)
150
+ payload_b64 = parts[1]
151
+ # Add padding if needed
152
+ padding = 4 - len(payload_b64) % 4
153
+ if padding != 4:
154
+ payload_b64 += "=" * padding
155
+ payload_bytes = base64.urlsafe_b64decode(payload_b64)
156
+ claims = json.loads(payload_bytes)
157
+
158
+ # Check expiration manually
159
+ exp = claims.get("exp")
160
+ if exp:
161
+ import time
162
+ if time.time() >= exp:
163
+ claims = None # Token expired
164
+ except Exception:
165
+ claims = None
166
+
167
+ # Refresh token if expired
168
+ if not claims and refresh_token:
169
+ console.print("[dim]Access token expired. Attempting to refresh...[/dim]")
170
+ try:
171
+ token_data = _refresh_token_with_retry(refresh_token)
172
+ new_access_token = token_data.get("access_token")
173
+ new_refresh_token = token_data.get("refresh_token")
174
+
175
+ if new_access_token:
176
+ try:
177
+ keyring.set_password(SERVICE_ID, "access_token", new_access_token)
178
+ 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
188
+ else:
189
+ console.print("[bold red]Failed to get new access token.[/bold red]")
190
+ return None
191
+
192
+ except httpx.TimeoutException:
193
+ console.print("[bold red]Token refresh failed: Request timed out.[/bold red]")
194
+ return None
195
+ except httpx.NetworkError:
196
+ console.print("[bold red]Token refresh failed: Network error.[/bold red]")
197
+ return None
198
+ except httpx.HTTPStatusError as exc:
199
+ if exc.response.status_code == 400:
200
+ console.print("[bold red]Refresh token expired. Please log in again.[/bold red]")
201
+ else:
202
+ error_detail = "Unknown error"
203
+ try:
204
+ if exc.response.content:
205
+ content_type = exc.response.headers.get("content-type", "")
206
+ if "application/json" in content_type:
207
+ error_data = exc.response.json()
208
+ error_detail = error_data.get("detail", str(error_data))
209
+ except Exception:
210
+ error_detail = exc.response.text[:200] if exc.response.text else "Unknown error"
211
+
212
+ console.print(
213
+ f"[bold red]Token refresh failed: {exc.response.status_code} - {error_detail}[/bold red]"
214
+ )
215
+
216
+ # 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
222
+ return None
223
+ except ValueError as e:
224
+ console.print(f"[bold red]Token refresh failed: {e}[/bold red]")
225
+ return None
226
+ except Exception as e:
227
+ console.print(
228
+ f"[bold red]Token refresh failed: Unexpected error - {type(e).__name__}[/bold red]"
229
+ )
230
+ return None
231
+
232
+ return access_token
233
+
234
+
235
+ def clear_tokens():
236
+ """Clear stored access and refresh tokens."""
237
+ try:
238
+ keyring.delete_password(SERVICE_ID, "access_token")
239
+ keyring.delete_password(SERVICE_ID, "refresh_token")
240
+ except keyring.errors.PasswordDeleteError:
241
+ 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]")