agentic-fabriq-sdk 0.1.5__py3-none-any.whl → 0.1.6__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/__init__.py +8 -0
- af_cli/commands/__init__.py +3 -0
- af_cli/commands/agents.py +238 -0
- af_cli/commands/auth.py +388 -0
- af_cli/commands/config.py +102 -0
- af_cli/commands/mcp_servers.py +83 -0
- af_cli/commands/secrets.py +109 -0
- af_cli/commands/tools.py +83 -0
- af_cli/core/__init__.py +3 -0
- af_cli/core/client.py +123 -0
- af_cli/core/config.py +200 -0
- af_cli/core/oauth.py +506 -0
- af_cli/core/output.py +180 -0
- af_cli/core/token_storage.py +263 -0
- af_cli/main.py +187 -0
- {agentic_fabriq_sdk-0.1.5.dist-info → agentic_fabriq_sdk-0.1.6.dist-info}/METADATA +37 -7
- {agentic_fabriq_sdk-0.1.5.dist-info → agentic_fabriq_sdk-0.1.6.dist-info}/RECORD +19 -3
- agentic_fabriq_sdk-0.1.6.dist-info/entry_points.txt +3 -0
- {agentic_fabriq_sdk-0.1.5.dist-info → agentic_fabriq_sdk-0.1.6.dist-info}/WHEEL +0 -0
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
|
+
|