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