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 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,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
- 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
+ # 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._id_token = ""
79
- self._id_token_expiration = datetime.now(UTC)
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 (jupyterlite or skip_auth):
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(f"Invalid domain: {domain}. Must be a 'questek.com' subdomain")
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 to https:// format."""
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
- domain = f"https://{domain}"
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
- return response.json()
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 Cognito-specific context."""
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 message
155
- error_msg = error_data.get("error") or error_data.get("message", "")
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 types to enhanced messages
286
+ # Map Django error codes to enhanced messages
158
287
  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",
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 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}"
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 for Cognito IdP detection
320
+ # Server config discovery
198
321
  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
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._id_token_expiration - self._token_expire_buffer <= datetime.now(UTC)
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._id_token and not self._is_token_expired():
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
- try:
242
- self._refresh_access_token()
395
+ if self._refresh_access_token():
243
396
  return
244
- except Exception:
245
- # Clear invalid refresh token
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
- def _refresh_access_token(self) -> None:
257
- """Refresh access token using refresh token."""
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, json={"refreshToken": self._refresh_token}, timeout=self._default_timeout
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
- 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)
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 as e:
277
- raise ICMDConnectionError(f"Network error during token refresh: {e}") from e
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 username/password."""
454
+ """Authenticate with email/password (Django JWT)."""
290
455
  try:
291
- username = input("Your ICMD® email: ").strip()
292
- if not username:
293
- raise ICMDValidationError("Username cannot be empty")
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={"username": username, "password": password},
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
- if auth_data.get("challenge"):
314
- self._handle_mfa_challenge(auth_data["challenge"], username, mfa_code)
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, challenge: dict, username: str, mfa_code: str) -> None:
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
- if not mfa_code:
332
- mfa_code = input("Your MFA Code: ").strip()
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
- if not mfa_code:
335
- raise ICMDValidationError("MFA code cannot be empty")
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
- 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
- )
520
+ if not mfa_code:
521
+ raise ICMDValidationError("MFA code cannot be empty")
348
522
 
349
- if response.status_code != HTTPStatus.OK:
350
- error_msg = "MFA verification failed"
351
- try:
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
- 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)
544
+ error = error_data.get("error", {})
545
+ error_code = error.get("code", "") if isinstance(error, dict) else ""
358
546
 
359
- self._handle_auth_response(response)
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
- except KeyboardInterrupt:
364
- raise ICMDAuthenticationError("MFA verification cancelled by user") from None
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
- print("\n\033[94mSigning into ICMD® with SAML SSO...\033[0m\n")
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
- 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")
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
- auth_code = input("Please enter the authentication code: ").strip()
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={"code": auth_code, "redirect_uri": f"{self.api_root}/account/auth/exchange"},
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("\n\033[92mSigned in successfully.\033[0m\n")
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
- # Update session headers
407
- if user_context := auth_data.get("userFingerprint"):
408
- self._session.headers["X-User-Context"] = user_context
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._id_token = auth_data.get("idToken", "")
412
- if refresh_token := auth_data.get("refreshToken"):
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
- # 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)
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._id_token:
424
- self._session.headers["Authorization"] = f"Bearer {self._id_token}"
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("_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)
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.")