icmd-python 1.3.2__py3-none-any.whl → 1.5.8__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.
- icmd/core/client.py +475 -145
- icmd/core/credentials.py +46 -9
- icmd/core/slim_packages.py +444 -0
- icmd/version.py +1 -1
- {icmd_python-1.3.2.dist-info → icmd_python-1.5.8.dist-info}/METADATA +8 -2
- icmd_python-1.5.8.dist-info/RECORD +10 -0
- icmd_python-1.3.2.dist-info/RECORD +0 -9
- {icmd_python-1.3.2.dist-info → icmd_python-1.5.8.dist-info}/WHEEL +0 -0
- {icmd_python-1.3.2.dist-info → icmd_python-1.5.8.dist-info}/top_level.txt +0 -0
icmd/core/client.py
CHANGED
|
@@ -5,12 +5,16 @@ import webbrowser
|
|
|
5
5
|
from datetime import UTC, datetime, timedelta
|
|
6
6
|
from getpass import getpass
|
|
7
7
|
from http import HTTPStatus
|
|
8
|
-
from typing import Any
|
|
8
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pathlib import Path
|
|
9
12
|
|
|
10
13
|
import requests
|
|
11
14
|
from requests.adapters import HTTPAdapter
|
|
12
15
|
|
|
13
16
|
from .credentials import CredentialManager
|
|
17
|
+
from .slim_packages import ensure_slim_packages
|
|
14
18
|
|
|
15
19
|
|
|
16
20
|
class ICMDAuthenticationError(Exception):
|
|
@@ -35,20 +39,50 @@ class ICMD:
|
|
|
35
39
|
_DEFAULT_TIMEOUT = 30
|
|
36
40
|
_TOKEN_EXPIRE_BUFFER_MS = 5000
|
|
37
41
|
_HTTP_NOT_FOUND = 404
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
_REFRESH_TIMEOUT = 10
|
|
43
|
+
|
|
44
|
+
# Endpoints excluded from auto-refresh retry
|
|
45
|
+
EXCLUDED_ENDPOINTS: ClassVar[set[str]] = {
|
|
46
|
+
"account/auth/login",
|
|
47
|
+
"account/auth/refresh",
|
|
48
|
+
"account/auth/logout",
|
|
49
|
+
"account/auth/mfa/verify",
|
|
50
|
+
"account/auth/saml/login",
|
|
51
|
+
"account/auth/saml/acs",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Session-wide qtpy_slim tracking (shared across all ICMD instances)
|
|
55
|
+
# Prevents loading conflicting package versions within same Python session
|
|
56
|
+
_session_qtpy_version: dict | None = None
|
|
57
|
+
_session_qtpy_server: str | None = None
|
|
58
|
+
_session_qtpy_path: "Path | None" = None
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
domain: str,
|
|
63
|
+
auth_method: str | None = None,
|
|
64
|
+
developer_token: str | None = None,
|
|
65
|
+
clear_cache: bool = False,
|
|
66
|
+
skip_auth: bool = False,
|
|
67
|
+
):
|
|
40
68
|
"""Initialize ICMD client with required domain parameter.
|
|
41
69
|
|
|
42
70
|
Args:
|
|
43
71
|
domain: REQUIRED domain (e.g., 'icmd.questek.com', 'cust.icmd.questek.com')
|
|
44
|
-
|
|
72
|
+
auth_method: Override auth method detection. Options: "SAML" or "PASSWORD".
|
|
73
|
+
If not specified, auto-detects based on server config (prefers SAML).
|
|
74
|
+
developer_token: Developer API token for non-interactive use (scripts, CI/CD).
|
|
75
|
+
When provided, skips all authentication flows.
|
|
76
|
+
clear_cache: If True, clears cached credentials before initialization,
|
|
77
|
+
forcing fresh authentication. Useful for switching accounts or
|
|
78
|
+
troubleshooting authentication issues.
|
|
45
79
|
skip_auth: Skip auto-authentication (useful for testing or non-interactive
|
|
46
80
|
environments)
|
|
47
81
|
|
|
48
82
|
Raises
|
|
49
83
|
------
|
|
50
84
|
ValueError
|
|
51
|
-
If domain is not provided or invalid
|
|
85
|
+
If domain is not provided or invalid, or if auth_method is invalid
|
|
52
86
|
"""
|
|
53
87
|
if not domain:
|
|
54
88
|
raise ValueError("Domain is required: ICMD('icmd.questek.com')")
|
|
@@ -70,24 +104,57 @@ class ICMD:
|
|
|
70
104
|
# Set reasonable timeouts by default
|
|
71
105
|
self._default_timeout = self._DEFAULT_TIMEOUT
|
|
72
106
|
|
|
107
|
+
# Cache for server config (reduces redundant HTTP requests)
|
|
108
|
+
self._server_config_cache = None
|
|
109
|
+
|
|
73
110
|
self._credentials = CredentialManager()
|
|
74
|
-
|
|
75
|
-
|
|
111
|
+
|
|
112
|
+
# Clear cached credentials if requested
|
|
113
|
+
if clear_cache:
|
|
114
|
+
self._credentials.clear_domain_credentials(self.domain)
|
|
115
|
+
|
|
116
|
+
# Handle developer token authentication (non-interactive)
|
|
117
|
+
if developer_token:
|
|
118
|
+
self._session.headers["Authorization"] = f"Bearer {developer_token}"
|
|
119
|
+
self._developer_token = developer_token
|
|
120
|
+
# Don't fail ICMD initialization if slim package management fails
|
|
121
|
+
with contextlib.suppress(Exception):
|
|
122
|
+
ensure_slim_packages(self)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
self._developer_token = None
|
|
126
|
+
|
|
127
|
+
# Validate and set auth method
|
|
128
|
+
if auth_method:
|
|
129
|
+
if auth_method.upper() not in ("SAML", "PASSWORD"):
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"Invalid auth_method: {auth_method}. Must be 'SAML' or 'PASSWORD'"
|
|
132
|
+
)
|
|
133
|
+
self.auth_method = auth_method.upper()
|
|
134
|
+
else:
|
|
135
|
+
self.auth_method = self._auto_detect_auth_method()
|
|
76
136
|
|
|
77
137
|
self._refresh_token = ""
|
|
78
|
-
self.
|
|
79
|
-
self.
|
|
138
|
+
self._access_token = ""
|
|
139
|
+
self._access_token_expiration = datetime.now(UTC)
|
|
80
140
|
self._token_expire_buffer = timedelta(milliseconds=self._TOKEN_EXPIRE_BUFFER_MS)
|
|
81
141
|
|
|
82
142
|
# Initialize default timeout (needed for tests that bypass __init__)
|
|
83
143
|
if not hasattr(self, "_default_timeout"):
|
|
84
144
|
self._default_timeout = self._DEFAULT_TIMEOUT
|
|
85
145
|
|
|
146
|
+
# Display banner message if available (before auth prompts)
|
|
147
|
+
self._display_banner_message()
|
|
148
|
+
|
|
86
149
|
self._load_cached_credentials()
|
|
87
150
|
|
|
88
|
-
if not
|
|
151
|
+
if not skip_auth:
|
|
89
152
|
self._ensure_authenticated()
|
|
90
153
|
|
|
154
|
+
# Don't fail ICMD initialization if slim package management fails
|
|
155
|
+
with contextlib.suppress(Exception):
|
|
156
|
+
ensure_slim_packages(self)
|
|
157
|
+
|
|
91
158
|
def _validate_domain(self, domain: str) -> str:
|
|
92
159
|
"""Validate domain format and return normalized domain.
|
|
93
160
|
|
|
@@ -108,14 +175,26 @@ class ICMD:
|
|
|
108
175
|
|
|
109
176
|
normalized = self._normalize_domain(domain)
|
|
110
177
|
|
|
178
|
+
# Accept localhost for local development
|
|
179
|
+
# Check both the original domain and the normalized one
|
|
180
|
+
domain_to_check = domain.replace("http://", "").replace("https://", "")
|
|
181
|
+
if self._is_local_domain(domain_to_check):
|
|
182
|
+
return normalized
|
|
183
|
+
|
|
111
184
|
# Accept: icmd.questek.com or *.icmd.questek.com
|
|
112
185
|
if ".questek.com" in normalized:
|
|
113
186
|
return normalized
|
|
114
187
|
|
|
115
|
-
raise ValueError(
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"Invalid domain: {domain}. "
|
|
190
|
+
"Must be a 'questek.com' subdomain or localhost for development"
|
|
191
|
+
)
|
|
116
192
|
|
|
117
193
|
def _normalize_domain(self, domain: str) -> str:
|
|
118
|
-
"""Normalize domain
|
|
194
|
+
"""Normalize domain with appropriate protocol.
|
|
195
|
+
|
|
196
|
+
Uses http:// for localhost, https:// otherwise.
|
|
197
|
+
"""
|
|
119
198
|
if not isinstance(domain, str):
|
|
120
199
|
raise ICMDValidationError("Domain must be a string")
|
|
121
200
|
|
|
@@ -124,25 +203,71 @@ class ICMD:
|
|
|
124
203
|
raise ICMDValidationError("Domain cannot be empty")
|
|
125
204
|
|
|
126
205
|
if not domain.startswith("http"):
|
|
127
|
-
|
|
206
|
+
# Use http:// for localhost/local development, https:// for production
|
|
207
|
+
protocol = "http" if self._is_local_domain(domain) else "https"
|
|
208
|
+
domain = f"{protocol}://{domain}"
|
|
128
209
|
return domain
|
|
129
210
|
|
|
211
|
+
def _is_local_domain(self, domain: str) -> bool:
|
|
212
|
+
"""Check if domain is a local development domain.
|
|
213
|
+
|
|
214
|
+
Args
|
|
215
|
+
----
|
|
216
|
+
domain: Domain string to check (without protocol)
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
True if domain is localhost, 127.0.0.1, or similar local patterns
|
|
221
|
+
"""
|
|
222
|
+
domain_lower = domain.lower()
|
|
223
|
+
# Check for localhost and local IP addresses
|
|
224
|
+
return domain_lower.startswith(("localhost", "127.", "0.0.0.0", "[::1]"))
|
|
225
|
+
|
|
226
|
+
def _display_banner_message(self) -> None:
|
|
227
|
+
"""Display banner message from server config if available."""
|
|
228
|
+
try:
|
|
229
|
+
config = self._get_server_config()
|
|
230
|
+
banner_message = config.get("BANNER_MESSAGE", "").strip()
|
|
231
|
+
|
|
232
|
+
if banner_message:
|
|
233
|
+
# Display banner in a noticeable format with colored border
|
|
234
|
+
border = "=" * 70
|
|
235
|
+
print(f"\n\033[96m{border}\033[0m")
|
|
236
|
+
print(f"\033[96m{banner_message}\033[0m")
|
|
237
|
+
print(f"\033[96m{border}\033[0m\n")
|
|
238
|
+
except Exception:
|
|
239
|
+
# Silently fail if banner retrieval fails
|
|
240
|
+
pass
|
|
241
|
+
|
|
130
242
|
def _get_server_config(self) -> dict:
|
|
131
|
-
"""Fetch server configuration from /api/v1/public/config/ endpoint.
|
|
243
|
+
"""Fetch server configuration from /api/v1/public/config/ endpoint.
|
|
244
|
+
|
|
245
|
+
Results are cached per instance to avoid redundant HTTP requests during initialization.
|
|
246
|
+
Empty configs are not cached to allow retry.
|
|
247
|
+
"""
|
|
248
|
+
# Return cached config if available and non-empty
|
|
249
|
+
if self._server_config_cache:
|
|
250
|
+
return self._server_config_cache
|
|
251
|
+
|
|
132
252
|
try:
|
|
133
253
|
config_url = f"{self.api_root}/public/config/"
|
|
134
254
|
response = self._session.get(config_url, timeout=5)
|
|
135
255
|
|
|
136
256
|
if response.status_code == HTTPStatus.OK:
|
|
137
|
-
|
|
257
|
+
config = response.json()
|
|
258
|
+
# Only cache non-empty successful responses
|
|
259
|
+
if config:
|
|
260
|
+
self._server_config_cache = config
|
|
261
|
+
return config
|
|
138
262
|
except (requests.RequestException, ValueError, OSError, Exception):
|
|
139
263
|
# Network issues, JSON parsing errors, timeouts, or any other errors
|
|
140
264
|
pass
|
|
141
265
|
|
|
266
|
+
# Don't cache failures or empty responses - allow retry on next call
|
|
142
267
|
return {}
|
|
143
268
|
|
|
144
269
|
def _enhance_auth_error(self, error_response: requests.Response, auth_method: str) -> str:
|
|
145
|
-
"""Enhance authentication error messages with
|
|
270
|
+
"""Enhance authentication error messages with Django-specific context."""
|
|
146
271
|
base_error = f"{auth_method} authentication failed"
|
|
147
272
|
enhanced_error = base_error
|
|
148
273
|
|
|
@@ -151,38 +276,38 @@ class ICMD:
|
|
|
151
276
|
if not isinstance(error_data, dict):
|
|
152
277
|
return enhanced_error
|
|
153
278
|
|
|
154
|
-
# Extract error
|
|
155
|
-
|
|
279
|
+
# Extract error details from Django response
|
|
280
|
+
error = error_data.get("error", {})
|
|
281
|
+
if isinstance(error, dict):
|
|
282
|
+
error_code = error.get("code", "")
|
|
283
|
+
error_msg = error.get("message", "")
|
|
284
|
+
else:
|
|
285
|
+
error_code = ""
|
|
286
|
+
error_msg = error_data.get("message", "") or error_data.get("detail", "")
|
|
156
287
|
|
|
157
|
-
# Map error
|
|
288
|
+
# Map Django error codes to enhanced messages
|
|
158
289
|
error_enhancements = {
|
|
159
|
-
"
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
"
|
|
290
|
+
"invalid_credentials": "Invalid email or password",
|
|
291
|
+
"account_inactive": "Account is inactive, contact administrator",
|
|
292
|
+
"invalid_token": "Invalid or expired token",
|
|
293
|
+
"invalid_code": "Invalid or expired MFA code",
|
|
294
|
+
"mfa_required": "Multi-factor authentication required",
|
|
295
|
+
"mfa_setup_required": (
|
|
296
|
+
"MFA not configured. Please configure MFA at "
|
|
297
|
+
f"https://{self.domain.replace('https://', '')}/account/security/"
|
|
298
|
+
),
|
|
299
|
+
"rate_limit_exceeded": "Too many attempts, please wait before trying again",
|
|
164
300
|
}
|
|
165
301
|
|
|
166
|
-
# Check for specific error
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
enhanced_error = f"{base_error}: Incorrect username or password"
|
|
176
|
-
else:
|
|
177
|
-
enhanced_error = f"{base_error}: Access denied by identity provider"
|
|
178
|
-
# Handle networking errors
|
|
179
|
-
elif "NetworkingError" in error_msg or "timeout" in error_msg.lower():
|
|
180
|
-
enhanced_error = (
|
|
181
|
-
f"{base_error}: Network connectivity issue with identity provider"
|
|
182
|
-
)
|
|
183
|
-
# Generic error message if available
|
|
184
|
-
elif error_msg:
|
|
185
|
-
enhanced_error = f"{base_error}: {error_msg}"
|
|
302
|
+
# Check for specific error codes
|
|
303
|
+
if error_code in error_enhancements:
|
|
304
|
+
enhanced_error = f"{base_error}: {error_enhancements[error_code]}"
|
|
305
|
+
# Handle networking errors
|
|
306
|
+
elif "network" in error_msg.lower() or "timeout" in error_msg.lower():
|
|
307
|
+
enhanced_error = f"{base_error}: Network connectivity issue"
|
|
308
|
+
# Generic error message if available
|
|
309
|
+
elif error_msg:
|
|
310
|
+
enhanced_error = f"{base_error}: {error_msg}"
|
|
186
311
|
|
|
187
312
|
except (ValueError, KeyError):
|
|
188
313
|
pass
|
|
@@ -194,22 +319,49 @@ class ICMD:
|
|
|
194
319
|
if cached := self._credentials.get_auth_method(self.domain):
|
|
195
320
|
return cached
|
|
196
321
|
|
|
197
|
-
# Server config discovery
|
|
322
|
+
# Server config discovery
|
|
198
323
|
config = self._get_server_config()
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
324
|
+
|
|
325
|
+
# Check Django AUTH_METHODS config
|
|
326
|
+
if auth_methods := config.get("AUTH_METHODS", []):
|
|
327
|
+
has_saml = any(method.get("type") == "saml" for method in auth_methods)
|
|
328
|
+
has_password = any(method.get("type") == "password" for method in auth_methods)
|
|
329
|
+
|
|
330
|
+
# If both methods available, let user choose
|
|
331
|
+
if has_saml and has_password:
|
|
332
|
+
return self._prompt_auth_method_selection()
|
|
333
|
+
|
|
334
|
+
if has_saml:
|
|
335
|
+
return "SAML"
|
|
336
|
+
if has_password:
|
|
337
|
+
return "PASSWORD"
|
|
206
338
|
|
|
207
339
|
# Fallback: When server config is unavailable, default to PASSWORD
|
|
208
|
-
# NOTE: The consent endpoint exists for both UP-only and SAML-enabled instances,
|
|
209
|
-
# so we cannot reliably determine SAML availability without server config.
|
|
210
|
-
# Conservative approach: assume PASSWORD when config is unavailable.
|
|
211
340
|
return "PASSWORD"
|
|
212
341
|
|
|
342
|
+
def _prompt_auth_method_selection(self) -> str:
|
|
343
|
+
"""Prompt user to select authentication method when multiple options available."""
|
|
344
|
+
try:
|
|
345
|
+
print("\n\033[94mMultiple authentication methods available:\033[0m")
|
|
346
|
+
print(" [1] SAML SSO [2] Password")
|
|
347
|
+
|
|
348
|
+
while True:
|
|
349
|
+
try:
|
|
350
|
+
choice = input("Select authentication method (1 or 2): ").strip()
|
|
351
|
+
|
|
352
|
+
if choice == "1":
|
|
353
|
+
return "SAML"
|
|
354
|
+
if choice == "2":
|
|
355
|
+
return "PASSWORD"
|
|
356
|
+
print("Invalid choice. Please enter 1 or 2.")
|
|
357
|
+
except KeyboardInterrupt:
|
|
358
|
+
# Default to SAML on Ctrl+C
|
|
359
|
+
print("\nDefaulting to SAML SSO...")
|
|
360
|
+
return "SAML"
|
|
361
|
+
except EOFError:
|
|
362
|
+
# Non-interactive environment - default to SAML
|
|
363
|
+
return "SAML"
|
|
364
|
+
|
|
213
365
|
@property
|
|
214
366
|
def api_root(self) -> str:
|
|
215
367
|
"""Return the root of the ICMD API."""
|
|
@@ -228,23 +380,24 @@ class ICMD:
|
|
|
228
380
|
|
|
229
381
|
def _is_token_expired(self) -> bool:
|
|
230
382
|
"""Check if current token is expired."""
|
|
231
|
-
return self.
|
|
383
|
+
return self._access_token_expiration - self._token_expire_buffer <= datetime.now(UTC)
|
|
232
384
|
|
|
233
385
|
def _ensure_authenticated(self) -> None:
|
|
234
386
|
"""Ensure we have a valid authentication token with smart refresh."""
|
|
387
|
+
# Developer tokens bypass authentication flow
|
|
388
|
+
if hasattr(self, "_developer_token") and self._developer_token:
|
|
389
|
+
return
|
|
390
|
+
|
|
235
391
|
# Check if we already have a valid token
|
|
236
|
-
if self.
|
|
392
|
+
if self._access_token and not self._is_token_expired():
|
|
237
393
|
return
|
|
238
394
|
|
|
239
395
|
# Try refresh token if available
|
|
240
396
|
if self._refresh_token:
|
|
241
|
-
|
|
242
|
-
self._refresh_access_token()
|
|
397
|
+
if self._refresh_access_token():
|
|
243
398
|
return
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
self._refresh_token = ""
|
|
247
|
-
self._credentials.clear_refresh_token(self.domain)
|
|
399
|
+
# Clear invalid refresh token from storage
|
|
400
|
+
self._credentials.clear_refresh_token(self.domain)
|
|
248
401
|
|
|
249
402
|
# Full authentication required
|
|
250
403
|
self._perform_authentication()
|
|
@@ -253,28 +406,42 @@ class ICMD:
|
|
|
253
406
|
"""Manually trigger authentication. Useful when ICMD was created with skip_auth=True."""
|
|
254
407
|
self._ensure_authenticated()
|
|
255
408
|
|
|
256
|
-
|
|
257
|
-
|
|
409
|
+
# Don't fail if slim package management fails
|
|
410
|
+
with contextlib.suppress(Exception):
|
|
411
|
+
ensure_slim_packages(self)
|
|
412
|
+
|
|
413
|
+
def _refresh_access_token(self) -> bool:
|
|
414
|
+
"""Refresh access token using refresh token.
|
|
415
|
+
|
|
416
|
+
Returns
|
|
417
|
+
-------
|
|
418
|
+
bool: True if refresh successful, False otherwise
|
|
419
|
+
"""
|
|
258
420
|
try:
|
|
259
421
|
url = f"{self.api_root}/account/auth/refresh/"
|
|
260
422
|
response = self._session.post(
|
|
261
|
-
url,
|
|
423
|
+
url,
|
|
424
|
+
json={"refresh_token": self._refresh_token},
|
|
425
|
+
timeout=self._REFRESH_TIMEOUT,
|
|
262
426
|
)
|
|
263
427
|
|
|
264
428
|
if response.status_code != HTTPStatus.OK:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
except (ValueError, KeyError):
|
|
271
|
-
pass
|
|
272
|
-
raise ICMDAuthenticationError(error_msg)
|
|
429
|
+
# Clear tokens on failure
|
|
430
|
+
self._access_token = ""
|
|
431
|
+
self._refresh_token = ""
|
|
432
|
+
self._session.headers.pop("Authorization", None)
|
|
433
|
+
return False
|
|
273
434
|
|
|
435
|
+
# Handle token rotation (new refresh_token in response)
|
|
274
436
|
self._handle_auth_response(response)
|
|
437
|
+
return True
|
|
275
438
|
|
|
276
|
-
except requests.RequestException
|
|
277
|
-
|
|
439
|
+
except (requests.RequestException, Exception):
|
|
440
|
+
# Clear tokens on any error
|
|
441
|
+
self._access_token = ""
|
|
442
|
+
self._refresh_token = ""
|
|
443
|
+
self._session.headers.pop("Authorization", None)
|
|
444
|
+
return False
|
|
278
445
|
|
|
279
446
|
def _perform_authentication(self) -> None:
|
|
280
447
|
"""Perform full authentication flow."""
|
|
@@ -286,22 +453,20 @@ class ICMD:
|
|
|
286
453
|
self._save_credentials()
|
|
287
454
|
|
|
288
455
|
def _password_authentication(self) -> None:
|
|
289
|
-
"""Authenticate with
|
|
456
|
+
"""Authenticate with email/password (Django JWT)."""
|
|
290
457
|
try:
|
|
291
|
-
|
|
292
|
-
if not
|
|
293
|
-
raise ICMDValidationError("
|
|
458
|
+
email = input("Your ICMD® email: ").strip()
|
|
459
|
+
if not email:
|
|
460
|
+
raise ICMDValidationError("Email cannot be empty")
|
|
294
461
|
|
|
295
462
|
password = getpass("Your ICMD® password: ")
|
|
296
463
|
if not password:
|
|
297
464
|
raise ICMDValidationError("Password cannot be empty")
|
|
298
465
|
|
|
299
|
-
mfa_code = input("Your MFA Code (if enabled): ").strip()
|
|
300
|
-
|
|
301
466
|
url = f"{self.api_root}/account/auth/login/"
|
|
302
467
|
response = self._session.post(
|
|
303
468
|
url,
|
|
304
|
-
json={"
|
|
469
|
+
json={"email": email, "password": password},
|
|
305
470
|
timeout=self._default_timeout,
|
|
306
471
|
)
|
|
307
472
|
|
|
@@ -310,8 +475,10 @@ class ICMD:
|
|
|
310
475
|
raise ICMDAuthenticationError(error_msg)
|
|
311
476
|
|
|
312
477
|
auth_data = response.json()
|
|
313
|
-
|
|
314
|
-
|
|
478
|
+
|
|
479
|
+
# Check if MFA is required
|
|
480
|
+
if auth_data.get("requires_mfa"):
|
|
481
|
+
self._handle_mfa_challenge(auth_data)
|
|
315
482
|
else:
|
|
316
483
|
self._handle_auth_response(response)
|
|
317
484
|
|
|
@@ -325,65 +492,176 @@ class ICMD:
|
|
|
325
492
|
except KeyboardInterrupt:
|
|
326
493
|
raise ICMDAuthenticationError("Authentication cancelled by user") from None
|
|
327
494
|
|
|
328
|
-
def _handle_mfa_challenge(self,
|
|
329
|
-
"""Handle MFA challenge."""
|
|
495
|
+
def _handle_mfa_challenge(self, mfa_response: dict) -> None:
|
|
496
|
+
"""Handle Django MFA challenge with retry logic."""
|
|
330
497
|
try:
|
|
331
|
-
|
|
332
|
-
|
|
498
|
+
# Extract session token and available methods
|
|
499
|
+
session_token = mfa_response.get("session_token")
|
|
500
|
+
available_methods = mfa_response.get("available_methods", [])
|
|
501
|
+
|
|
502
|
+
if not session_token:
|
|
503
|
+
raise ICMDAuthenticationError("Invalid MFA response: missing session token")
|
|
504
|
+
|
|
505
|
+
# Detect MFA method
|
|
506
|
+
if "totp" in available_methods:
|
|
507
|
+
method = "totp"
|
|
508
|
+
prompt = "Enter MFA code from authenticator app: "
|
|
509
|
+
elif "email" in available_methods:
|
|
510
|
+
method = "email"
|
|
511
|
+
prompt = "MFA code sent to your email. Enter code: "
|
|
512
|
+
else:
|
|
513
|
+
method = available_methods[0] if available_methods else "totp"
|
|
514
|
+
prompt = "Enter MFA code: "
|
|
333
515
|
|
|
334
|
-
|
|
335
|
-
|
|
516
|
+
# Allow up to 3 attempts
|
|
517
|
+
max_attempts = 3
|
|
518
|
+
for attempt in range(1, max_attempts + 1):
|
|
519
|
+
try:
|
|
520
|
+
mfa_code = input(prompt).strip()
|
|
336
521
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
url,
|
|
340
|
-
json={
|
|
341
|
-
"mfa_code": mfa_code,
|
|
342
|
-
"session": challenge["Session"],
|
|
343
|
-
"username": username,
|
|
344
|
-
"challenge_name": challenge.get("ChallengeName"),
|
|
345
|
-
},
|
|
346
|
-
timeout=self._default_timeout,
|
|
347
|
-
)
|
|
522
|
+
if not mfa_code:
|
|
523
|
+
raise ICMDValidationError("MFA code cannot be empty")
|
|
348
524
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
525
|
+
url = f"{self.api_root}/account/auth/mfa/verify/"
|
|
526
|
+
|
|
527
|
+
# Set session token in Authorization header (not in body)
|
|
528
|
+
headers = {"Authorization": f"Session {session_token}"}
|
|
529
|
+
|
|
530
|
+
response = self._session.post(
|
|
531
|
+
url,
|
|
532
|
+
json={
|
|
533
|
+
"method": method,
|
|
534
|
+
"code": mfa_code,
|
|
535
|
+
},
|
|
536
|
+
headers=headers,
|
|
537
|
+
timeout=self._default_timeout,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
if response.status_code == HTTPStatus.OK:
|
|
541
|
+
self._handle_auth_response(response)
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
# Handle error
|
|
352
545
|
error_data = response.json()
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
except (ValueError, KeyError):
|
|
356
|
-
pass
|
|
357
|
-
raise ICMDAuthenticationError(error_msg)
|
|
546
|
+
error = error_data.get("error", {})
|
|
547
|
+
error_code = error.get("code", "") if isinstance(error, dict) else ""
|
|
358
548
|
|
|
359
|
-
|
|
549
|
+
if error_code == "invalid_code" and attempt < max_attempts:
|
|
550
|
+
remaining = max_attempts - attempt
|
|
551
|
+
print(f"Invalid code. {remaining} attempt(s) remaining.")
|
|
552
|
+
continue
|
|
553
|
+
error_msg = self._enhance_auth_error(response, "MFA")
|
|
554
|
+
raise ICMDAuthenticationError(error_msg)
|
|
555
|
+
|
|
556
|
+
except KeyboardInterrupt:
|
|
557
|
+
raise ICMDAuthenticationError("MFA verification cancelled by user") from None
|
|
558
|
+
|
|
559
|
+
raise ICMDAuthenticationError("MFA verification failed: Maximum attempts exceeded")
|
|
360
560
|
|
|
361
561
|
except requests.RequestException as e:
|
|
362
562
|
raise ICMDConnectionError(f"Network error during MFA verification: {e}") from e
|
|
363
|
-
|
|
364
|
-
|
|
563
|
+
|
|
564
|
+
def _get_saml_provider(self) -> dict:
|
|
565
|
+
"""Get SAML provider configuration from server.
|
|
566
|
+
|
|
567
|
+
Returns
|
|
568
|
+
-------
|
|
569
|
+
dict: SAML provider info with 'id', 'name', and 'login_url'
|
|
570
|
+
|
|
571
|
+
Raises
|
|
572
|
+
------
|
|
573
|
+
ICMDAuthenticationError: If no SAML providers configured or selection fails
|
|
574
|
+
"""
|
|
575
|
+
try:
|
|
576
|
+
config = self._get_server_config()
|
|
577
|
+
saml_providers = config.get("SAML_PROVIDERS", [])
|
|
578
|
+
|
|
579
|
+
if not saml_providers:
|
|
580
|
+
raise ICMDAuthenticationError(
|
|
581
|
+
"SAML authentication is not configured on this server. "
|
|
582
|
+
"Please contact your administrator or use password authentication."
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
# If only one provider, auto-select
|
|
586
|
+
if len(saml_providers) == 1:
|
|
587
|
+
provider = saml_providers[0]
|
|
588
|
+
print(f"Using SAML provider: {provider.get('name', provider['id'])}")
|
|
589
|
+
return provider
|
|
590
|
+
|
|
591
|
+
# Multiple providers - let user choose
|
|
592
|
+
print("\n\033[94mMultiple SAML providers available:\033[0m")
|
|
593
|
+
for idx, provider in enumerate(saml_providers, 1):
|
|
594
|
+
provider_name = provider.get("name", provider["id"])
|
|
595
|
+
description = provider.get("description", "")
|
|
596
|
+
if description:
|
|
597
|
+
print(f" [{idx}] {provider_name} - {description}")
|
|
598
|
+
else:
|
|
599
|
+
print(f" [{idx}] {provider_name}")
|
|
600
|
+
|
|
601
|
+
n_providers = len(saml_providers)
|
|
602
|
+
while True:
|
|
603
|
+
try:
|
|
604
|
+
choice = input(f"\nSelect SAML provider (1-{n_providers}): ").strip()
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
choice_idx = int(choice) - 1
|
|
608
|
+
if 0 <= choice_idx < n_providers:
|
|
609
|
+
return saml_providers[choice_idx]
|
|
610
|
+
print(
|
|
611
|
+
f"Invalid choice. Please enter a number between 1 and {n_providers}."
|
|
612
|
+
)
|
|
613
|
+
except ValueError:
|
|
614
|
+
print(f"Invalid input. Please enter a number between 1 and {n_providers}.")
|
|
615
|
+
|
|
616
|
+
except KeyboardInterrupt:
|
|
617
|
+
# Default to first provider on Ctrl+C
|
|
618
|
+
print(
|
|
619
|
+
f"\nDefaulting to {
|
|
620
|
+
saml_providers[0].get('name', saml_providers[0]['id'])
|
|
621
|
+
}..."
|
|
622
|
+
)
|
|
623
|
+
return saml_providers[0]
|
|
624
|
+
|
|
625
|
+
except EOFError:
|
|
626
|
+
# Non-interactive environment - use first provider
|
|
627
|
+
if saml_providers:
|
|
628
|
+
return saml_providers[0]
|
|
629
|
+
raise ICMDAuthenticationError(
|
|
630
|
+
"Cannot select SAML provider in non-interactive environment"
|
|
631
|
+
) from None
|
|
365
632
|
|
|
366
633
|
def _saml_authentication(self) -> None:
|
|
367
|
-
"""Authenticate with SAML SSO."""
|
|
634
|
+
"""Authenticate with Django SAML SSO."""
|
|
368
635
|
try:
|
|
369
|
-
|
|
636
|
+
from urllib.parse import quote
|
|
637
|
+
|
|
638
|
+
# Get SAML provider configuration
|
|
639
|
+
provider = self._get_saml_provider()
|
|
640
|
+
|
|
641
|
+
# Use provider's login URL with SDK flag in relay_state
|
|
642
|
+
relay_state = quote("/auth/callback?sdk=true")
|
|
643
|
+
consent_url = f"{self.domain}{provider['login_url']}&relay_state={relay_state}"
|
|
370
644
|
|
|
371
|
-
|
|
372
|
-
print(f"
|
|
373
|
-
print("If it doesn't open, copy this URL to your browser:")
|
|
374
|
-
print(f"\n{consent_url}\n")
|
|
645
|
+
# Display the authentication link
|
|
646
|
+
print(f"\n\033[94mSAML SSO Authentication Link:\033[0m\n{consent_url}")
|
|
375
647
|
|
|
648
|
+
# Compact messaging for Jupyter notebooks
|
|
649
|
+
print("\n\033[94mOpening browser...\033[0m Copy the auth code (5 min expiry)")
|
|
650
|
+
|
|
651
|
+
# Attempt to open browser automatically
|
|
376
652
|
with contextlib.suppress(Exception):
|
|
377
653
|
webbrowser.open(consent_url)
|
|
378
654
|
|
|
379
|
-
|
|
655
|
+
# Compact input prompt
|
|
656
|
+
auth_code = input("Auth Code: ").strip()
|
|
380
657
|
if not auth_code:
|
|
381
658
|
raise ICMDValidationError("Authentication code required")
|
|
382
659
|
|
|
660
|
+
# Exchange auth code for tokens
|
|
383
661
|
url = f"{self.api_root}/account/auth/login/"
|
|
384
662
|
response = self._session.post(
|
|
385
663
|
url,
|
|
386
|
-
json={"
|
|
664
|
+
json={"auth_code": auth_code},
|
|
387
665
|
timeout=self._default_timeout,
|
|
388
666
|
)
|
|
389
667
|
|
|
@@ -392,7 +670,7 @@ class ICMD:
|
|
|
392
670
|
raise ICMDAuthenticationError(error_msg)
|
|
393
671
|
|
|
394
672
|
self._handle_auth_response(response)
|
|
395
|
-
print("\
|
|
673
|
+
print("\033[92m✓ Authenticated\033[0m")
|
|
396
674
|
|
|
397
675
|
except requests.RequestException as e:
|
|
398
676
|
raise ICMDConnectionError(f"Network error during SAML authentication: {e}") from e
|
|
@@ -400,28 +678,31 @@ class ICMD:
|
|
|
400
678
|
raise ICMDAuthenticationError("SAML authentication cancelled by user") from None
|
|
401
679
|
|
|
402
680
|
def _handle_auth_response(self, response: requests.Response) -> None:
|
|
403
|
-
"""Handle authentication response and extract tokens."""
|
|
681
|
+
"""Handle Django JWT authentication response and extract tokens."""
|
|
404
682
|
auth_data = response.json()
|
|
405
683
|
|
|
406
|
-
#
|
|
407
|
-
if
|
|
408
|
-
|
|
684
|
+
# Check for error response
|
|
685
|
+
if not auth_data.get("success", False):
|
|
686
|
+
error = auth_data.get("error", {})
|
|
687
|
+
error_msg = error.get("message", "Authentication failed")
|
|
688
|
+
raise ICMDAuthenticationError(error_msg)
|
|
689
|
+
|
|
690
|
+
# Update session headers with user UUID
|
|
691
|
+
if (user_data := auth_data.get("user")) and (user_uuid := user_data.get("uuid")):
|
|
692
|
+
self._session.headers["X-User-Context"] = user_uuid
|
|
409
693
|
|
|
410
694
|
# Extract tokens
|
|
411
|
-
self.
|
|
412
|
-
if refresh_token := auth_data.get("
|
|
695
|
+
self._access_token = auth_data.get("access_token", "")
|
|
696
|
+
if refresh_token := auth_data.get("refresh_token"):
|
|
413
697
|
self._refresh_token = refresh_token
|
|
414
698
|
|
|
415
|
-
#
|
|
416
|
-
if
|
|
417
|
-
self.
|
|
418
|
-
# Ensure timezone awareness for comparison
|
|
419
|
-
if self._id_token_expiration.tzinfo is None:
|
|
420
|
-
self._id_token_expiration = self._id_token_expiration.replace(tzinfo=UTC)
|
|
699
|
+
# Calculate expiration from expires_in (seconds)
|
|
700
|
+
if expires_in := auth_data.get("expires_in"):
|
|
701
|
+
self._access_token_expiration = datetime.now(UTC) + timedelta(seconds=expires_in)
|
|
421
702
|
|
|
422
703
|
# Set bearer auth
|
|
423
|
-
if self.
|
|
424
|
-
self._session.headers["Authorization"] = f"Bearer {self.
|
|
704
|
+
if self._access_token:
|
|
705
|
+
self._session.headers["Authorization"] = f"Bearer {self._access_token}"
|
|
425
706
|
|
|
426
707
|
# Handle cookies
|
|
427
708
|
for cookie in response.cookies:
|
|
@@ -448,15 +729,35 @@ class ICMD:
|
|
|
448
729
|
response = self._session.request(method, url, **kwargs)
|
|
449
730
|
|
|
450
731
|
# Handle token expiration with single retry
|
|
451
|
-
if response.status_code == HTTPStatus.UNAUTHORIZED and not kwargs.get(
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
self.
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
732
|
+
if response.status_code == HTTPStatus.UNAUTHORIZED and not kwargs.get(
|
|
733
|
+
"_retry_attempted"
|
|
734
|
+
):
|
|
735
|
+
# Check if endpoint is excluded from auto-refresh
|
|
736
|
+
if endpoint in self.EXCLUDED_ENDPOINTS:
|
|
737
|
+
return response
|
|
738
|
+
|
|
739
|
+
# Check error detail for selective logout
|
|
740
|
+
try:
|
|
741
|
+
error_data = response.json()
|
|
742
|
+
error_detail = error_data.get("detail", "").lower()
|
|
743
|
+
# Only auto-refresh for specific token-related errors
|
|
744
|
+
if any(
|
|
745
|
+
msg in error_detail
|
|
746
|
+
for msg in ["invalid token", "token has expired", "not authenticated"]
|
|
747
|
+
):
|
|
748
|
+
# Set retry flag to prevent infinite loop
|
|
749
|
+
kwargs["_retry_attempted"] = True
|
|
750
|
+
|
|
751
|
+
# Attempt token refresh
|
|
752
|
+
if self._refresh_access_token():
|
|
753
|
+
# Retry with refreshed token
|
|
754
|
+
response = self._session.request(method, url, **kwargs)
|
|
755
|
+
else:
|
|
756
|
+
# Refresh failed, return original 401 response
|
|
757
|
+
return response
|
|
758
|
+
except (ValueError, KeyError):
|
|
759
|
+
# If response is not JSON or doesn't have expected structure, don't retry
|
|
760
|
+
pass
|
|
460
761
|
|
|
461
762
|
return response
|
|
462
763
|
|
|
@@ -483,3 +784,32 @@ class ICMD:
|
|
|
483
784
|
def delete(self, endpoint: str, **kwargs) -> requests.Response:
|
|
484
785
|
"""DELETE request."""
|
|
485
786
|
return self.request("DELETE", endpoint, **kwargs)
|
|
787
|
+
|
|
788
|
+
def logout(self, clear_saved_credentials: bool = False) -> None:
|
|
789
|
+
"""Logout and clear session.
|
|
790
|
+
|
|
791
|
+
Args:
|
|
792
|
+
clear_saved_credentials: If True, also clear cached credentials from disk.
|
|
793
|
+
Default False (preserves refresh token for faster re-auth).
|
|
794
|
+
"""
|
|
795
|
+
# Best effort logout call to server (ignore errors)
|
|
796
|
+
try:
|
|
797
|
+
if self._access_token:
|
|
798
|
+
url = f"{self.api_root}/account/auth/logout/"
|
|
799
|
+
self._session.post(url, timeout=5)
|
|
800
|
+
except Exception:
|
|
801
|
+
pass # Ignore errors, proceed with local cleanup
|
|
802
|
+
|
|
803
|
+
# Clear in-memory tokens
|
|
804
|
+
self._access_token = ""
|
|
805
|
+
self._refresh_token = ""
|
|
806
|
+
self._access_token_expiration = datetime.now(UTC)
|
|
807
|
+
|
|
808
|
+
# Remove Authorization header
|
|
809
|
+
self._session.headers.pop("Authorization", None)
|
|
810
|
+
|
|
811
|
+
# Optionally clear saved credentials
|
|
812
|
+
if clear_saved_credentials:
|
|
813
|
+
self._credentials.clear_refresh_token(self.domain)
|
|
814
|
+
|
|
815
|
+
print("Logged out successfully.")
|