agentic-fabriq-sdk 0.1.5__py3-none-any.whl → 0.1.7__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.

Potentially problematic release.


This version of agentic-fabriq-sdk might be problematic. Click here for more details.

af_cli/core/config.py ADDED
@@ -0,0 +1,200 @@
1
+ """
2
+ Configuration management for the Agentic Fabric CLI.
3
+ """
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ class CLIConfig(BaseModel):
14
+ """CLI configuration model."""
15
+
16
+ # Gateway settings
17
+ gateway_url: str = Field(default="https://dashboard.agenticfabriq.com", description="Gateway URL")
18
+
19
+ # Keycloak settings
20
+ keycloak_url: str = Field(default="https://auth.agenticfabriq.com", description="Keycloak URL")
21
+ keycloak_realm: str = Field(default="agentic-fabric", description="Keycloak realm")
22
+ keycloak_client_id: str = Field(default="agentic-fabriq-cli", description="Keycloak client ID for CLI")
23
+
24
+ # Authentication (deprecated - now stored in token_storage, kept for backward compatibility)
25
+ access_token: Optional[str] = Field(default=None, description="Access token (deprecated)")
26
+ refresh_token: Optional[str] = Field(default=None, description="Refresh token (deprecated)")
27
+ token_expires_at: Optional[int] = Field(default=None, description="Token expiration timestamp (deprecated)")
28
+
29
+ # Tenant settings
30
+ tenant_id: Optional[str] = Field(default=None, description="Tenant ID")
31
+
32
+ # CLI settings
33
+ config_file: str = Field(default="", description="Configuration file path")
34
+ verbose: bool = Field(default=False, description="Verbose output")
35
+ output_format: str = Field(default="table", description="Output format (table, json, yaml)")
36
+
37
+ def __init__(self, **data):
38
+ super().__init__(**data)
39
+ if not self.config_file:
40
+ self.config_file = self._get_default_config_path()
41
+
42
+ def _get_default_config_path(self) -> str:
43
+ """Get default configuration file path."""
44
+ home_dir = Path.home()
45
+ config_dir = home_dir / ".af"
46
+ return str(config_dir / "config.json")
47
+
48
+ def load(self) -> None:
49
+ """Load configuration from file."""
50
+ if os.path.exists(self.config_file):
51
+ try:
52
+ with open(self.config_file, 'r') as f:
53
+ data = json.load(f)
54
+
55
+ # Update fields from loaded data
56
+ for key, value in data.items():
57
+ if hasattr(self, key):
58
+ setattr(self, key, value)
59
+
60
+ except Exception as e:
61
+ print(f"Warning: Failed to load config from {self.config_file}: {e}")
62
+
63
+ def save(self) -> None:
64
+ """Save configuration to file."""
65
+ try:
66
+ # Create directory if it doesn't exist
67
+ os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
68
+
69
+ # Save configuration
70
+ with open(self.config_file, 'w') as f:
71
+ # Only save non-default values
72
+ data = {}
73
+ for key, value in self.dict().items():
74
+ if key in ['config_file', 'verbose']:
75
+ continue # Skip runtime-only fields
76
+ if value is not None:
77
+ data[key] = value
78
+
79
+ json.dump(data, f, indent=2)
80
+
81
+ except Exception as e:
82
+ print(f"Error: Failed to save config to {self.config_file}: {e}")
83
+
84
+ def clear_auth(self) -> None:
85
+ """Clear authentication tokens (deprecated - use token_storage)."""
86
+ self.access_token = None
87
+ self.refresh_token = None
88
+ self.token_expires_at = None
89
+ self.save()
90
+
91
+ def is_authenticated(self) -> bool:
92
+ """
93
+ Check if user is authenticated.
94
+
95
+ This method is deprecated. Use token_storage for authentication checks.
96
+ """
97
+ # Check token storage first
98
+ try:
99
+ from af_cli.core.token_storage import get_token_storage
100
+ token_storage = get_token_storage()
101
+ token_data = token_storage.load()
102
+ if token_data and not token_storage.is_token_expired(token_data):
103
+ return True
104
+ except Exception:
105
+ pass
106
+
107
+ # Fall back to config (backward compatibility)
108
+ return self.access_token is not None
109
+
110
+ def get_access_token(self) -> Optional[str]:
111
+ """
112
+ Get current access token, refreshing if necessary.
113
+
114
+ Returns:
115
+ Valid access token or None
116
+ """
117
+ try:
118
+ from af_cli.core.token_storage import get_token_storage
119
+ from af_cli.core.oauth import OAuth2Client
120
+
121
+ token_storage = get_token_storage()
122
+ token_data = token_storage.load()
123
+
124
+ if not token_data:
125
+ return None
126
+
127
+ # Check if token is expired
128
+ if token_storage.is_token_expired(token_data):
129
+ # Try to refresh
130
+ if token_data.refresh_token:
131
+ try:
132
+ oauth_client = OAuth2Client(
133
+ keycloak_url=self.keycloak_url,
134
+ realm=self.keycloak_realm,
135
+ client_id=self.keycloak_client_id
136
+ )
137
+
138
+ new_tokens = oauth_client.refresh_token(token_data.refresh_token)
139
+ new_token_data = token_storage.extract_token_info(new_tokens)
140
+
141
+ # Preserve tenant_id
142
+ if not new_token_data.tenant_id and token_data.tenant_id:
143
+ new_token_data.tenant_id = token_data.tenant_id
144
+
145
+ # Save new tokens
146
+ token_storage.save(new_token_data)
147
+
148
+ # Update config
149
+ self.access_token = new_token_data.access_token
150
+ self.refresh_token = new_token_data.refresh_token
151
+ self.token_expires_at = new_token_data.expires_at
152
+ self.save()
153
+
154
+ return new_token_data.access_token
155
+
156
+ except Exception:
157
+ # Refresh failed
158
+ return None
159
+ else:
160
+ return None
161
+
162
+ return token_data.access_token
163
+
164
+ except Exception:
165
+ # Fall back to config
166
+ return self.access_token
167
+
168
+ def get_headers(self) -> dict:
169
+ """Get HTTP headers for API requests."""
170
+ headers = {
171
+ "Content-Type": "application/json",
172
+ }
173
+
174
+ # Get access token (with auto-refresh)
175
+ access_token = self.get_access_token()
176
+ if access_token:
177
+ headers["Authorization"] = f"Bearer {access_token}"
178
+
179
+ if self.tenant_id:
180
+ headers["X-Tenant-Id"] = self.tenant_id
181
+
182
+ return headers
183
+
184
+
185
+ # Global configuration instance
186
+ _config: Optional[CLIConfig] = None
187
+
188
+
189
+ def get_config() -> CLIConfig:
190
+ """Get the global configuration instance."""
191
+ global _config
192
+ if _config is None:
193
+ _config = CLIConfig()
194
+ return _config
195
+
196
+
197
+ def set_config(config: CLIConfig) -> None:
198
+ """Set the global configuration instance."""
199
+ global _config
200
+ _config = config
af_cli/core/oauth.py ADDED
@@ -0,0 +1,506 @@
1
+ """
2
+ OAuth2/PKCE authentication flow for Agentic Fabric CLI.
3
+
4
+ This module implements the Authorization Code Flow with PKCE (Proof Key for Code Exchange)
5
+ for secure authentication without requiring client secrets.
6
+ """
7
+
8
+ import base64
9
+ import hashlib
10
+ import secrets
11
+ import socket
12
+ import threading
13
+ import time
14
+ import webbrowser
15
+ from http.server import BaseHTTPRequestHandler, HTTPServer
16
+ from typing import Dict, Optional, Tuple
17
+ from urllib.parse import parse_qs, urlencode, urlparse
18
+
19
+ import httpx
20
+ from rich.console import Console
21
+
22
+ console = Console()
23
+
24
+
25
+ class PKCEGenerator:
26
+ """Generate PKCE code verifier and challenge."""
27
+
28
+ @staticmethod
29
+ def generate_code_verifier(length: int = 128) -> str:
30
+ """
31
+ Generate a cryptographically random code verifier.
32
+
33
+ Args:
34
+ length: Length of the verifier (43-128 characters)
35
+
36
+ Returns:
37
+ Base64-URL-encoded random string
38
+ """
39
+ if not 43 <= length <= 128:
40
+ raise ValueError("Code verifier length must be between 43 and 128 characters")
41
+
42
+ # Generate random bytes
43
+ random_bytes = secrets.token_bytes(96) # 96 bytes = 128 base64 chars
44
+
45
+ # Base64-URL encode (no padding)
46
+ verifier = base64.urlsafe_b64encode(random_bytes).decode('utf-8')
47
+ verifier = verifier.rstrip('=') # Remove padding
48
+
49
+ return verifier[:length]
50
+
51
+ @staticmethod
52
+ def generate_code_challenge(verifier: str) -> str:
53
+ """
54
+ Generate a code challenge from the verifier using S256 method.
55
+
56
+ Args:
57
+ verifier: The code verifier
58
+
59
+ Returns:
60
+ Base64-URL-encoded SHA256 hash of the verifier
61
+ """
62
+ # SHA256 hash
63
+ digest = hashlib.sha256(verifier.encode('utf-8')).digest()
64
+
65
+ # Base64-URL encode (no padding)
66
+ challenge = base64.urlsafe_b64encode(digest).decode('utf-8')
67
+ challenge = challenge.rstrip('=') # Remove padding
68
+
69
+ return challenge
70
+
71
+
72
+ class OAuthCallbackHandler(BaseHTTPRequestHandler):
73
+ """HTTP request handler for OAuth callback."""
74
+
75
+ # Class variables to share data between handler and server
76
+ authorization_code: Optional[str] = None
77
+ error: Optional[str] = None
78
+ state: Optional[str] = None
79
+
80
+ def do_GET(self):
81
+ """Handle GET request to callback endpoint."""
82
+ try:
83
+ # Parse query parameters
84
+ parsed_path = urlparse(self.path)
85
+ query_params = parse_qs(parsed_path.query)
86
+
87
+ # Extract authorization code
88
+ if 'code' in query_params:
89
+ OAuthCallbackHandler.authorization_code = query_params['code'][0]
90
+ OAuthCallbackHandler.state = query_params.get('state', [None])[0]
91
+
92
+ # Send success response
93
+ self.send_response(200)
94
+ self.send_header('Content-type', 'text/html')
95
+ self.end_headers()
96
+
97
+ success_html = """
98
+ <!DOCTYPE html>
99
+ <html>
100
+ <head>
101
+ <title>Authentication Successful</title>
102
+ <style>
103
+ body {
104
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
105
+ display: flex;
106
+ justify-content: center;
107
+ align-items: center;
108
+ height: 100vh;
109
+ margin: 0;
110
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
111
+ }
112
+ .container {
113
+ background: white;
114
+ padding: 40px;
115
+ border-radius: 10px;
116
+ box-shadow: 0 10px 40px rgba(0,0,0,0.2);
117
+ text-align: center;
118
+ }
119
+ h1 { color: #667eea; margin-bottom: 20px; }
120
+ p { color: #666; font-size: 16px; }
121
+ .checkmark {
122
+ width: 80px;
123
+ height: 80px;
124
+ margin: 0 auto 20px;
125
+ }
126
+ </style>
127
+ </head>
128
+ <body>
129
+ <div class="container">
130
+ <svg class="checkmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
131
+ <circle cx="26" cy="26" r="25" fill="none" stroke="#667eea" stroke-width="2"/>
132
+ <path fill="none" stroke="#667eea" stroke-width="4" d="M14 27l7 7 16-16"/>
133
+ </svg>
134
+ <h1>Authentication Successful!</h1>
135
+ <p>You have been successfully authenticated.</p>
136
+ <p>You can now close this window and return to the terminal.</p>
137
+ </div>
138
+ </body>
139
+ </html>
140
+ """
141
+ self.wfile.write(success_html.encode('utf-8'))
142
+
143
+ elif 'error' in query_params:
144
+ OAuthCallbackHandler.error = query_params['error'][0]
145
+ error_description = query_params.get('error_description', ['Unknown error'])[0]
146
+
147
+ # Send error response
148
+ self.send_response(400)
149
+ self.send_header('Content-type', 'text/html')
150
+ self.end_headers()
151
+
152
+ error_html = f"""
153
+ <!DOCTYPE html>
154
+ <html>
155
+ <head>
156
+ <title>Authentication Failed</title>
157
+ <style>
158
+ body {{
159
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
160
+ display: flex;
161
+ justify-content: center;
162
+ align-items: center;
163
+ height: 100vh;
164
+ margin: 0;
165
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
166
+ }}
167
+ .container {{
168
+ background: white;
169
+ padding: 40px;
170
+ border-radius: 10px;
171
+ box-shadow: 0 10px 40px rgba(0,0,0,0.2);
172
+ text-align: center;
173
+ }}
174
+ h1 {{ color: #f5576c; margin-bottom: 20px; }}
175
+ p {{ color: #666; font-size: 16px; }}
176
+ </style>
177
+ </head>
178
+ <body>
179
+ <div class="container">
180
+ <h1>Authentication Failed</h1>
181
+ <p>{error_description}</p>
182
+ <p>Please try again or contact support.</p>
183
+ </div>
184
+ </body>
185
+ </html>
186
+ """
187
+ self.wfile.write(error_html.encode('utf-8'))
188
+
189
+ except Exception as e:
190
+ console.print(f"[red]Error handling callback: {e}[/red]")
191
+ self.send_response(500)
192
+ self.end_headers()
193
+
194
+ def log_message(self, format, *args):
195
+ """Suppress default logging."""
196
+ pass
197
+
198
+
199
+ class LocalCallbackServer:
200
+ """Local HTTP server for OAuth callback."""
201
+
202
+ def __init__(self, port: int = 8089):
203
+ """
204
+ Initialize callback server.
205
+
206
+ Args:
207
+ port: Port to listen on (default: 8089)
208
+ """
209
+ self.port = port
210
+ self.server: Optional[HTTPServer] = None
211
+ self.thread: Optional[threading.Thread] = None
212
+
213
+ def _find_free_port(self) -> int:
214
+ """Find a free port to use."""
215
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
216
+ s.bind(('', 0))
217
+ s.listen(1)
218
+ port = s.getsockname()[1]
219
+ return port
220
+
221
+ def start(self) -> int:
222
+ """
223
+ Start the callback server.
224
+
225
+ Returns:
226
+ The port the server is listening on
227
+ """
228
+ # Reset handler state before starting
229
+ OAuthCallbackHandler.authorization_code = None
230
+ OAuthCallbackHandler.error = None
231
+ OAuthCallbackHandler.state = None
232
+
233
+ # Try to bind to the specified port, fall back to a free port if busy
234
+ max_attempts = 5
235
+ for attempt in range(max_attempts):
236
+ try:
237
+ # Create server
238
+ self.server = HTTPServer(('localhost', self.port), OAuthCallbackHandler)
239
+ break
240
+ except OSError as e:
241
+ if attempt < max_attempts - 1:
242
+ # Port is busy, try next port
243
+ self.port += 1
244
+ else:
245
+ raise Exception(f"Could not start server on ports 8089-{self.port}: {e}")
246
+
247
+ # Start server in background thread
248
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
249
+ self.thread.start()
250
+
251
+ return self.port
252
+
253
+ def wait_for_callback(self, timeout: int = 300) -> Tuple[Optional[str], Optional[str], Optional[str]]:
254
+ """
255
+ Wait for OAuth callback with authorization code.
256
+
257
+ Args:
258
+ timeout: Maximum time to wait in seconds (default: 5 minutes)
259
+
260
+ Returns:
261
+ Tuple of (authorization_code, state, error)
262
+ """
263
+ start_time = time.time()
264
+
265
+ while time.time() - start_time < timeout:
266
+ if OAuthCallbackHandler.authorization_code or OAuthCallbackHandler.error:
267
+ break
268
+ time.sleep(0.1)
269
+
270
+ # Get results
271
+ code = OAuthCallbackHandler.authorization_code
272
+ state = OAuthCallbackHandler.state
273
+ error = OAuthCallbackHandler.error
274
+
275
+ # Clean up
276
+ self.stop()
277
+
278
+ return code, state, error
279
+
280
+ def stop(self):
281
+ """Stop the callback server."""
282
+ if self.server:
283
+ self.server.shutdown()
284
+ self.server.server_close()
285
+
286
+ # Reset handler state
287
+ OAuthCallbackHandler.authorization_code = None
288
+ OAuthCallbackHandler.error = None
289
+ OAuthCallbackHandler.state = None
290
+
291
+
292
+ class OAuth2Client:
293
+ """OAuth2/PKCE client for Keycloak."""
294
+
295
+ def __init__(
296
+ self,
297
+ keycloak_url: str,
298
+ realm: str,
299
+ client_id: str,
300
+ scopes: Optional[list] = None
301
+ ):
302
+ """
303
+ Initialize OAuth2 client.
304
+
305
+ Args:
306
+ keycloak_url: Keycloak base URL (e.g., https://auth.agenticfabriq.com or http://localhost:8080 for local)
307
+ realm: Keycloak realm name
308
+ client_id: Client ID for the CLI
309
+ scopes: List of OAuth scopes to request
310
+ """
311
+ self.keycloak_url = keycloak_url.rstrip('/')
312
+ self.realm = realm
313
+ self.client_id = client_id
314
+ self.scopes = scopes or ['openid', 'profile', 'email']
315
+
316
+ # Endpoints
317
+ self.auth_endpoint = f"{self.keycloak_url}/realms/{self.realm}/protocol/openid-connect/auth"
318
+ self.token_endpoint = f"{self.keycloak_url}/realms/{self.realm}/protocol/openid-connect/token"
319
+ self.userinfo_endpoint = f"{self.keycloak_url}/realms/{self.realm}/protocol/openid-connect/userinfo"
320
+ self.logout_endpoint = f"{self.keycloak_url}/realms/{self.realm}/protocol/openid-connect/logout"
321
+
322
+ def login(self, open_browser: bool = True, timeout: int = 300, use_hosted_callback: bool = True) -> Dict[str, any]:
323
+ """
324
+ Perform OAuth2/PKCE login flow.
325
+
326
+ Args:
327
+ open_browser: Whether to automatically open the browser
328
+ timeout: Maximum time to wait for login (seconds)
329
+ use_hosted_callback: Use branded hosted callback page (default: True)
330
+
331
+ Returns:
332
+ Dictionary containing access_token, refresh_token, expires_in, etc.
333
+
334
+ Raises:
335
+ Exception: If authentication fails
336
+ """
337
+ # Generate PKCE parameters
338
+ code_verifier = PKCEGenerator.generate_code_verifier()
339
+ code_challenge = PKCEGenerator.generate_code_challenge(code_verifier)
340
+ state = secrets.token_urlsafe(32)
341
+
342
+ # Start local callback server
343
+ callback_server = LocalCallbackServer()
344
+ callback_port = callback_server.start()
345
+
346
+ # Determine redirect URI
347
+ # Use hosted page that shows success message and redirects back to localhost
348
+ if use_hosted_callback:
349
+ # Extract base URL from keycloak_url (assumes gateway is on same domain)
350
+ gateway_url = self.keycloak_url.replace('auth.', 'dashboard.')
351
+ redirect_uri = f"{gateway_url}/cli-callback?port={callback_port}"
352
+ else:
353
+ # Direct localhost redirect (fallback)
354
+ redirect_uri = f"http://localhost:{callback_port}/callback"
355
+
356
+ # Build authorization URL
357
+ auth_params = {
358
+ 'client_id': self.client_id,
359
+ 'response_type': 'code',
360
+ 'redirect_uri': redirect_uri,
361
+ 'scope': ' '.join(self.scopes),
362
+ 'state': state,
363
+ 'code_challenge': code_challenge,
364
+ 'code_challenge_method': 'S256',
365
+ }
366
+
367
+ auth_url = f"{self.auth_endpoint}?{urlencode(auth_params)}"
368
+
369
+ # Display instructions
370
+ console.print("\n[bold cyan]Opening browser for authentication...[/bold cyan]")
371
+ console.print(f"[dim]If browser doesn't open, visit: {auth_url}[/dim]\n")
372
+
373
+ # Open browser
374
+ if open_browser:
375
+ try:
376
+ webbrowser.open(auth_url)
377
+ except Exception as e:
378
+ console.print(f"[yellow]Warning: Could not open browser: {e}[/yellow]")
379
+ console.print(f"[yellow]Please manually visit: {auth_url}[/yellow]")
380
+
381
+ console.print("[bold]Waiting for login to complete...[/bold]")
382
+
383
+ # Wait for callback
384
+ auth_code, returned_state, error = callback_server.wait_for_callback(timeout)
385
+
386
+ if error:
387
+ raise Exception(f"Authentication failed: {error}")
388
+
389
+ if not auth_code:
390
+ raise Exception("Authentication timed out. Please try again.")
391
+
392
+ if returned_state != state:
393
+ raise Exception("State mismatch. Possible CSRF attack.")
394
+
395
+ # Exchange authorization code for tokens
396
+ console.print("[bold green]✓[/bold green] Authorization received, exchanging for tokens...")
397
+
398
+ token_data = {
399
+ 'grant_type': 'authorization_code',
400
+ 'client_id': self.client_id,
401
+ 'code': auth_code,
402
+ 'redirect_uri': redirect_uri,
403
+ 'code_verifier': code_verifier,
404
+ }
405
+
406
+ try:
407
+ response = httpx.post(
408
+ self.token_endpoint,
409
+ data=token_data,
410
+ headers={'Content-Type': 'application/x-www-form-urlencoded'},
411
+ timeout=30.0
412
+ )
413
+ response.raise_for_status()
414
+
415
+ tokens = response.json()
416
+ return tokens
417
+
418
+ except httpx.HTTPStatusError as e:
419
+ error_detail = e.response.text
420
+ raise Exception(f"Token exchange failed: {error_detail}")
421
+ except Exception as e:
422
+ raise Exception(f"Token exchange error: {e}")
423
+
424
+ def refresh_token(self, refresh_token: str) -> Dict[str, any]:
425
+ """
426
+ Refresh an expired access token.
427
+
428
+ Args:
429
+ refresh_token: The refresh token
430
+
431
+ Returns:
432
+ Dictionary containing new tokens
433
+
434
+ Raises:
435
+ Exception: If refresh fails
436
+ """
437
+ token_data = {
438
+ 'grant_type': 'refresh_token',
439
+ 'client_id': self.client_id,
440
+ 'refresh_token': refresh_token,
441
+ }
442
+
443
+ try:
444
+ response = httpx.post(
445
+ self.token_endpoint,
446
+ data=token_data,
447
+ headers={'Content-Type': 'application/x-www-form-urlencoded'},
448
+ timeout=30.0
449
+ )
450
+ response.raise_for_status()
451
+
452
+ return response.json()
453
+
454
+ except httpx.HTTPStatusError as e:
455
+ raise Exception(f"Token refresh failed: {e.response.text}")
456
+ except Exception as e:
457
+ raise Exception(f"Token refresh error: {e}")
458
+
459
+ def get_user_info(self, access_token: str) -> Dict[str, any]:
460
+ """
461
+ Get user information using access token.
462
+
463
+ Args:
464
+ access_token: The access token
465
+
466
+ Returns:
467
+ Dictionary containing user information
468
+ """
469
+ try:
470
+ response = httpx.get(
471
+ self.userinfo_endpoint,
472
+ headers={'Authorization': f'Bearer {access_token}'},
473
+ timeout=30.0
474
+ )
475
+ response.raise_for_status()
476
+
477
+ return response.json()
478
+
479
+ except Exception as e:
480
+ raise Exception(f"Failed to get user info: {e}")
481
+
482
+ def logout(self, refresh_token: str) -> None:
483
+ """
484
+ Logout and revoke tokens.
485
+
486
+ Args:
487
+ refresh_token: The refresh token to revoke
488
+ """
489
+ try:
490
+ data = {
491
+ 'client_id': self.client_id,
492
+ 'refresh_token': refresh_token,
493
+ }
494
+
495
+ response = httpx.post(
496
+ self.logout_endpoint,
497
+ data=data,
498
+ headers={'Content-Type': 'application/x-www-form-urlencoded'},
499
+ timeout=30.0
500
+ )
501
+ response.raise_for_status()
502
+
503
+ except Exception:
504
+ # Ignore errors during logout
505
+ pass
506
+