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 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
- def __init__(self, domain: str, jupyterlite: bool = False, skip_auth: bool = False):
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
- jupyterlite: Skip auto-authentication for JupyterLite
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
- self.auth_method = self._auto_detect_auth_method()
75
- self.jupyterlite = jupyterlite
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._id_token = ""
79
- self._id_token_expiration = datetime.now(UTC)
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 (jupyterlite or skip_auth):
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(f"Invalid domain: {domain}. Must be a 'questek.com' subdomain")
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 to https:// format."""
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
- domain = f"https://{domain}"
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
- return response.json()
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 Cognito-specific context."""
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 message
155
- error_msg = error_data.get("error") or error_data.get("message", "")
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 types to enhanced messages
288
+ # Map Django error codes to enhanced messages
158
289
  error_enhancements = {
159
- "InvalidParameterException": "Invalid credentials or user not found",
160
- "UserNotFoundException": "User account not found",
161
- "CodeMismatchException": "Invalid authentication code",
162
- "ExpiredCodeException": "Authentication code has expired, please try again",
163
- "TooManyRequestsException": "Too many attempts, please wait before trying again",
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 types
167
- for error_type, enhancement in error_enhancements.items():
168
- if error_type in error_msg:
169
- enhanced_error = f"{base_error}: {enhancement}"
170
- break
171
- else:
172
- # Handle NotAuthorizedException with method-specific messages
173
- if "NotAuthorizedException" in error_msg:
174
- if auth_method == "PASSWORD":
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 for Cognito IdP detection
322
+ # Server config discovery
198
323
  config = self._get_server_config()
199
- if idps := config.get("identityProviders", []):
200
- # Prefer external IdP (Azure AD, etc.) over native Cognito password
201
- external_idps = [idp for idp in idps if idp != "UP"]
202
- if external_idps:
203
- return "SAML" # External IdP available
204
- if "UP" in idps:
205
- return "PASSWORD" # Cognito native user/password
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._id_token_expiration - self._token_expire_buffer <= datetime.now(UTC)
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._id_token and not self._is_token_expired():
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
- try:
242
- self._refresh_access_token()
397
+ if self._refresh_access_token():
243
398
  return
244
- except Exception:
245
- # Clear invalid refresh token
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
- def _refresh_access_token(self) -> None:
257
- """Refresh access token using refresh token."""
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, json={"refreshToken": self._refresh_token}, timeout=self._default_timeout
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
- error_msg = "Token refresh failed"
266
- try:
267
- error_data = response.json()
268
- if isinstance(error_data, dict) and "error" in error_data:
269
- error_msg = f"Token refresh failed: {error_data['error']}"
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 as e:
277
- raise ICMDConnectionError(f"Network error during token refresh: {e}") from e
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 username/password."""
456
+ """Authenticate with email/password (Django JWT)."""
290
457
  try:
291
- username = input("Your ICMD® email: ").strip()
292
- if not username:
293
- raise ICMDValidationError("Username cannot be empty")
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={"username": username, "password": password},
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
- if auth_data.get("challenge"):
314
- self._handle_mfa_challenge(auth_data["challenge"], username, mfa_code)
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, challenge: dict, username: str, mfa_code: str) -> None:
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
- if not mfa_code:
332
- mfa_code = input("Your MFA Code: ").strip()
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
- if not mfa_code:
335
- raise ICMDValidationError("MFA code cannot be empty")
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
- url = f"{self.api_root}/account/auth/mfa/"
338
- response = self._session.post(
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
- if response.status_code != HTTPStatus.OK:
350
- error_msg = "MFA verification failed"
351
- try:
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
- if isinstance(error_data, dict) and "error" in error_data:
354
- error_msg = f"MFA failed: {error_data['error']}"
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
- self._handle_auth_response(response)
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
- except KeyboardInterrupt:
364
- raise ICMDAuthenticationError("MFA verification cancelled by user") from None
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
- print("\n\033[94mSigning into ICMD® with SAML SSO...\033[0m\n")
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
- consent_url = f"{self.api_root}/account/auth/consent"
372
- print(f"Opening browser: {consent_url}")
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
- auth_code = input("Please enter the authentication code: ").strip()
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={"code": auth_code, "redirect_uri": f"{self.api_root}/account/auth/exchange"},
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("\n\033[92mSigned in successfully.\033[0m\n")
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
- # Update session headers
407
- if user_context := auth_data.get("userFingerprint"):
408
- self._session.headers["X-User-Context"] = user_context
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._id_token = auth_data.get("idToken", "")
412
- if refresh_token := auth_data.get("refreshToken"):
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
- # Set expiration
416
- if expires_at := auth_data.get("expiresAt"):
417
- self._id_token_expiration = datetime.fromisoformat(expires_at)
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._id_token:
424
- self._session.headers["Authorization"] = f"Bearer {self._id_token}"
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("_retry_auth"):
452
- # Clear token and re-authenticate
453
- self._id_token = ""
454
- self._session.headers.pop("Authorization", None)
455
- self._ensure_authenticated()
456
-
457
- # Retry once with fresh token
458
- kwargs["_retry_auth"] = True
459
- response = self._session.request(method, url, **kwargs)
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.")