miso-client 0.1.0__py3-none-any.whl → 3.7.2__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.
Files changed (69) hide show
  1. miso_client/__init__.py +523 -130
  2. miso_client/api/__init__.py +35 -0
  3. miso_client/api/auth_api.py +367 -0
  4. miso_client/api/logs_api.py +91 -0
  5. miso_client/api/permissions_api.py +88 -0
  6. miso_client/api/roles_api.py +88 -0
  7. miso_client/api/types/__init__.py +75 -0
  8. miso_client/api/types/auth_types.py +183 -0
  9. miso_client/api/types/logs_types.py +71 -0
  10. miso_client/api/types/permissions_types.py +31 -0
  11. miso_client/api/types/roles_types.py +31 -0
  12. miso_client/errors.py +30 -4
  13. miso_client/models/__init__.py +4 -0
  14. miso_client/models/config.py +275 -72
  15. miso_client/models/error_response.py +39 -0
  16. miso_client/models/filter.py +255 -0
  17. miso_client/models/pagination.py +44 -0
  18. miso_client/models/sort.py +25 -0
  19. miso_client/services/__init__.py +6 -5
  20. miso_client/services/auth.py +496 -87
  21. miso_client/services/cache.py +42 -41
  22. miso_client/services/encryption.py +18 -17
  23. miso_client/services/logger.py +467 -328
  24. miso_client/services/logger_chain.py +288 -0
  25. miso_client/services/permission.py +130 -67
  26. miso_client/services/redis.py +28 -23
  27. miso_client/services/role.py +145 -62
  28. miso_client/utils/__init__.py +3 -3
  29. miso_client/utils/audit_log_queue.py +222 -0
  30. miso_client/utils/auth_strategy.py +88 -0
  31. miso_client/utils/auth_utils.py +65 -0
  32. miso_client/utils/circuit_breaker.py +125 -0
  33. miso_client/utils/client_token_manager.py +244 -0
  34. miso_client/utils/config_loader.py +88 -17
  35. miso_client/utils/controller_url_resolver.py +80 -0
  36. miso_client/utils/data_masker.py +104 -33
  37. miso_client/utils/environment_token.py +126 -0
  38. miso_client/utils/error_utils.py +216 -0
  39. miso_client/utils/fastapi_endpoints.py +166 -0
  40. miso_client/utils/filter.py +364 -0
  41. miso_client/utils/filter_applier.py +143 -0
  42. miso_client/utils/filter_parser.py +110 -0
  43. miso_client/utils/flask_endpoints.py +169 -0
  44. miso_client/utils/http_client.py +494 -262
  45. miso_client/utils/http_client_logging.py +352 -0
  46. miso_client/utils/http_client_logging_helpers.py +197 -0
  47. miso_client/utils/http_client_query_helpers.py +138 -0
  48. miso_client/utils/http_error_handler.py +92 -0
  49. miso_client/utils/http_log_formatter.py +115 -0
  50. miso_client/utils/http_log_masker.py +203 -0
  51. miso_client/utils/internal_http_client.py +435 -0
  52. miso_client/utils/jwt_tools.py +125 -16
  53. miso_client/utils/logger_helpers.py +206 -0
  54. miso_client/utils/logging_helpers.py +70 -0
  55. miso_client/utils/origin_validator.py +128 -0
  56. miso_client/utils/pagination.py +275 -0
  57. miso_client/utils/request_context.py +285 -0
  58. miso_client/utils/sensitive_fields_loader.py +116 -0
  59. miso_client/utils/sort.py +116 -0
  60. miso_client/utils/token_utils.py +114 -0
  61. miso_client/utils/url_validator.py +66 -0
  62. miso_client/utils/user_token_refresh.py +245 -0
  63. miso_client-3.7.2.dist-info/METADATA +1021 -0
  64. miso_client-3.7.2.dist-info/RECORD +68 -0
  65. miso_client-0.1.0.dist-info/METADATA +0 -551
  66. miso_client-0.1.0.dist-info/RECORD +0 -23
  67. {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/WHEEL +0 -0
  68. {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/licenses/LICENSE +0 -0
  69. {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,285 @@
1
+ """Request context extraction utilities for HTTP requests."""
2
+
3
+ from typing import Any, Dict, Optional, Protocol, Tuple, runtime_checkable
4
+
5
+ from ..utils.jwt_tools import decode_token
6
+
7
+
8
+ @runtime_checkable
9
+ class RequestHeaders(Protocol):
10
+ """Protocol for request headers access."""
11
+
12
+ def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
13
+ """Get header value by key."""
14
+ ...
15
+
16
+
17
+ @runtime_checkable
18
+ class RequestClient(Protocol):
19
+ """Protocol for request client info."""
20
+
21
+ host: Optional[str]
22
+
23
+
24
+ @runtime_checkable
25
+ class RequestURL(Protocol):
26
+ """Protocol for request URL."""
27
+
28
+ path: str
29
+
30
+
31
+ @runtime_checkable
32
+ class HttpRequest(Protocol):
33
+ """
34
+ Protocol for HTTP request objects.
35
+
36
+ Supports:
37
+ - FastAPI/Starlette Request
38
+ - Flask Request
39
+ - Generic dict-like request objects
40
+ """
41
+
42
+ method: str
43
+ headers: RequestHeaders
44
+ client: Optional[RequestClient]
45
+ url: Optional[RequestURL]
46
+
47
+
48
+ class RequestContext:
49
+ """Container for extracted request context."""
50
+
51
+ def __init__(
52
+ self,
53
+ ip_address: Optional[str] = None,
54
+ method: Optional[str] = None,
55
+ path: Optional[str] = None,
56
+ user_agent: Optional[str] = None,
57
+ correlation_id: Optional[str] = None,
58
+ referer: Optional[str] = None,
59
+ user_id: Optional[str] = None,
60
+ session_id: Optional[str] = None,
61
+ request_id: Optional[str] = None,
62
+ request_size: Optional[int] = None,
63
+ ):
64
+ """Initialize request context."""
65
+ self.ip_address = ip_address
66
+ self.method = method
67
+ self.path = path
68
+ self.user_agent = user_agent
69
+ self.correlation_id = correlation_id
70
+ self.referer = referer
71
+ self.user_id = user_id
72
+ self.session_id = session_id
73
+ self.request_id = request_id
74
+ self.request_size = request_size
75
+
76
+ def to_dict(self) -> Dict[str, Any]:
77
+ """Convert to dictionary, excluding None values."""
78
+ return {k: v for k, v in self.__dict__.items() if v is not None}
79
+
80
+
81
+ def extract_request_context(request: Any) -> RequestContext:
82
+ """
83
+ Extract logging context from HTTP request object.
84
+
85
+ Supports multiple Python web frameworks:
86
+ - FastAPI/Starlette Request
87
+ - Flask Request
88
+ - Generic dict-like request objects
89
+
90
+ Args:
91
+ request: HTTP request object
92
+
93
+ Returns:
94
+ RequestContext with extracted fields
95
+
96
+ Example:
97
+ >>> from fastapi import Request
98
+ >>> ctx = extract_request_context(request)
99
+ >>> await logger.with_request(request).info("Processing")
100
+ """
101
+ # Extract IP address (handle proxies)
102
+ ip_address = _extract_ip_address(request)
103
+
104
+ # Extract correlation ID from common headers
105
+ correlation_id = _extract_correlation_id(request)
106
+
107
+ # Extract user from JWT if available
108
+ user_id, session_id = _extract_user_from_auth_header(request)
109
+
110
+ # Extract method
111
+ method = _extract_method(request)
112
+
113
+ # Extract path
114
+ path = _extract_path(request)
115
+
116
+ # Extract other headers
117
+ headers = _get_headers(request)
118
+ user_agent = headers.get("user-agent")
119
+ referer = headers.get("referer")
120
+ request_id = headers.get("x-request-id")
121
+
122
+ # Extract request size
123
+ content_length = headers.get("content-length")
124
+ request_size = int(content_length) if content_length else None
125
+
126
+ return RequestContext(
127
+ ip_address=ip_address,
128
+ method=method,
129
+ path=path,
130
+ user_agent=user_agent,
131
+ correlation_id=correlation_id,
132
+ referer=referer,
133
+ user_id=user_id,
134
+ session_id=session_id,
135
+ request_id=request_id,
136
+ request_size=request_size,
137
+ )
138
+
139
+
140
+ def _get_headers(request: Any) -> Dict[str, Optional[str]]:
141
+ """Get headers from request object."""
142
+ # FastAPI/Starlette
143
+ if hasattr(request, "headers"):
144
+ headers = request.headers
145
+ if hasattr(headers, "get"):
146
+ # Type cast to satisfy type checker - headers.get() returns Optional[str]
147
+ return headers # type: ignore[no-any-return]
148
+ # Convert to dict if needed
149
+ if hasattr(headers, "items"):
150
+ return dict(headers.items())
151
+ return {}
152
+
153
+
154
+ def _extract_ip_address(request: Any) -> Optional[str]:
155
+ """Extract client IP address, handling proxies."""
156
+ headers = _get_headers(request)
157
+
158
+ # Check x-forwarded-for first (proxy/load balancer)
159
+ forwarded_for = headers.get("x-forwarded-for")
160
+ if forwarded_for:
161
+ # Take first IP in chain
162
+ return forwarded_for.split(",")[0].strip()
163
+
164
+ # Check x-real-ip
165
+ real_ip = headers.get("x-real-ip")
166
+ if real_ip:
167
+ return real_ip
168
+
169
+ # FastAPI/Starlette: request.client.host
170
+ # Check if client exists and has host attribute with actual value
171
+ if hasattr(request, "client") and request.client:
172
+ try:
173
+ host = getattr(request.client, "host", None)
174
+ # Check if host is a real value (string) and not a MagicMock
175
+ if isinstance(host, str):
176
+ return host
177
+ except Exception:
178
+ pass
179
+
180
+ # Flask: request.remote_addr
181
+ if hasattr(request, "remote_addr"):
182
+ try:
183
+ remote_addr = getattr(request, "remote_addr", None)
184
+ # Check if remote_addr is a real value (string) and not a MagicMock
185
+ if isinstance(remote_addr, str):
186
+ return remote_addr
187
+ except Exception:
188
+ pass
189
+
190
+ return None
191
+
192
+
193
+ def _extract_correlation_id(request: Any) -> Optional[str]:
194
+ """Extract correlation ID from common headers."""
195
+ headers = _get_headers(request)
196
+
197
+ return (
198
+ headers.get("x-correlation-id")
199
+ or headers.get("x-request-id")
200
+ or headers.get("request-id")
201
+ or headers.get("traceparent") # W3C Trace Context
202
+ )
203
+
204
+
205
+ def _extract_method(request: Any) -> Optional[str]:
206
+ """Extract HTTP method from request."""
207
+ if hasattr(request, "method"):
208
+ try:
209
+ method = getattr(request, "method", None)
210
+ # Check if method is a real value (string) and not a MagicMock
211
+ if isinstance(method, str):
212
+ return method
213
+ except Exception:
214
+ pass
215
+ return None
216
+
217
+
218
+ def _extract_path(request: Any) -> Optional[str]:
219
+ """Extract request path from request."""
220
+ # FastAPI/Starlette: request.url.path
221
+ if hasattr(request, "url") and request.url:
222
+ try:
223
+ url_path = getattr(request.url, "path", None)
224
+ # Check if path is a real value (string) and not a MagicMock
225
+ if isinstance(url_path, str):
226
+ return url_path
227
+ except Exception:
228
+ pass
229
+
230
+ # Flask: request.path
231
+ if hasattr(request, "path"):
232
+ try:
233
+ path = getattr(request, "path", None)
234
+ # Check if path is a real value (string) and not a MagicMock
235
+ if isinstance(path, str):
236
+ return path
237
+ except Exception:
238
+ pass
239
+
240
+ # Try original_url (some frameworks)
241
+ if hasattr(request, "original_url"):
242
+ try:
243
+ original_url = getattr(request, "original_url", None)
244
+ # Check if original_url is a real value (string) and not a MagicMock
245
+ if isinstance(original_url, str):
246
+ return original_url
247
+ except Exception:
248
+ pass
249
+
250
+ return None
251
+
252
+
253
+ def _extract_user_from_auth_header(request: Any) -> Tuple[Optional[str], Optional[str]]:
254
+ """
255
+ Extract user ID and session ID from Authorization header JWT.
256
+
257
+ Args:
258
+ request: HTTP request object
259
+
260
+ Returns:
261
+ Tuple of (user_id, session_id)
262
+ """
263
+ headers = _get_headers(request)
264
+ auth_header = headers.get("authorization")
265
+
266
+ if not auth_header or not auth_header.startswith("Bearer "):
267
+ return None, None
268
+
269
+ try:
270
+ token = auth_header[7:] # Remove "Bearer " prefix
271
+ decoded = decode_token(token)
272
+ if not decoded:
273
+ return None, None
274
+
275
+ user_id = (
276
+ decoded.get("sub")
277
+ or decoded.get("userId")
278
+ or decoded.get("user_id")
279
+ or decoded.get("id")
280
+ )
281
+ session_id = decoded.get("sessionId") or decoded.get("sid")
282
+
283
+ return user_id, session_id
284
+ except Exception:
285
+ return None, None
@@ -0,0 +1,116 @@
1
+ """
2
+ Sensitive fields configuration loader for ISO 27001 compliance.
3
+
4
+ This module provides utilities to load and merge sensitive fields configuration
5
+ from JSON files, supporting custom configuration paths and environment variables.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ # Default path to sensitive fields config relative to this file
14
+ _DEFAULT_CONFIG_PATH = Path(__file__).parent / "sensitive_fields_config.json"
15
+
16
+
17
+ def load_sensitive_fields_config(
18
+ config_path: Optional[str] = None,
19
+ ) -> Dict[str, Any]:
20
+ """
21
+ Load sensitive fields configuration from JSON file.
22
+
23
+ Supports custom path via:
24
+ 1. config_path parameter
25
+ 2. MISO_SENSITIVE_FIELDS_CONFIG environment variable
26
+ 3. Default path: miso_client/utils/sensitive_fields_config.json
27
+
28
+ Args:
29
+ config_path: Optional custom path to JSON config file
30
+
31
+ Returns:
32
+ Dictionary with 'fields' and 'fieldPatterns' keys
33
+ Returns empty dict if file cannot be loaded
34
+
35
+ Example:
36
+ >>> config = load_sensitive_fields_config()
37
+ >>> fields = config.get('fields', {})
38
+ """
39
+ # Priority: parameter > environment variable > default
40
+ if config_path:
41
+ file_path = Path(config_path)
42
+ elif os.environ.get("MISO_SENSITIVE_FIELDS_CONFIG"):
43
+ file_path = Path(os.environ["MISO_SENSITIVE_FIELDS_CONFIG"])
44
+ else:
45
+ file_path = _DEFAULT_CONFIG_PATH
46
+
47
+ try:
48
+ with open(file_path, "r", encoding="utf-8") as f:
49
+ config = json.load(f)
50
+ # Validate structure
51
+ if isinstance(config, dict):
52
+ return config
53
+ return {}
54
+ except (FileNotFoundError, json.JSONDecodeError, IOError, OSError):
55
+ # File not found, invalid JSON, or permission error
56
+ # Return empty dict - fallback to hardcoded defaults
57
+ return {}
58
+
59
+
60
+ def get_sensitive_fields_array(
61
+ config_path: Optional[str] = None,
62
+ ) -> List[str]:
63
+ """
64
+ Get flattened array of all sensitive field names from configuration.
65
+
66
+ Args:
67
+ config_path: Optional custom path to JSON config file
68
+
69
+ Returns:
70
+ Flattened list of all sensitive field names from all categories
71
+ Returns empty list if config cannot be loaded
72
+
73
+ Example:
74
+ >>> fields = get_sensitive_fields_array()
75
+ >>> assert 'password' in fields
76
+ >>> assert 'token' in fields
77
+ """
78
+ config = load_sensitive_fields_config(config_path)
79
+ fields_dict = config.get("fields", {})
80
+
81
+ # Flatten all categories into single list
82
+ all_fields: List[str] = []
83
+ if isinstance(fields_dict, dict):
84
+ for category_fields in fields_dict.values():
85
+ if isinstance(category_fields, list):
86
+ all_fields.extend(category_fields)
87
+
88
+ # Remove duplicates while preserving order
89
+ seen = set()
90
+ unique_fields = []
91
+ for field in all_fields:
92
+ if field.lower() not in seen:
93
+ seen.add(field.lower())
94
+ unique_fields.append(field)
95
+
96
+ return unique_fields
97
+
98
+
99
+ def get_field_patterns(config_path: Optional[str] = None) -> List[str]:
100
+ """
101
+ Get field pattern matching rules from configuration.
102
+
103
+ Args:
104
+ config_path: Optional custom path to JSON config file
105
+
106
+ Returns:
107
+ List of field pattern matching rules
108
+ Returns empty list if config cannot be loaded or no patterns defined
109
+
110
+ Example:
111
+ >>> patterns = get_field_patterns()
112
+ >>> # Patterns can be regex patterns or simple matching rules
113
+ """
114
+ config = load_sensitive_fields_config(config_path)
115
+ patterns = config.get("fieldPatterns", [])
116
+ return patterns if isinstance(patterns, list) else []
@@ -0,0 +1,116 @@
1
+ """
2
+ Sort utilities for MisoClient SDK.
3
+
4
+ This module provides reusable sort utilities for parsing sort parameters
5
+ and building sort query strings.
6
+ """
7
+
8
+ from typing import List, cast
9
+ from urllib.parse import quote
10
+
11
+ from ..models.sort import SortOption, SortOrder
12
+
13
+
14
+ def parse_sort_params(params: dict) -> List[SortOption]:
15
+ """
16
+ Parse sort query parameters into SortOption list.
17
+
18
+ Parses `?sort=-field` format into SortOption objects.
19
+ Supports multiple sort parameters (array of sort strings).
20
+ Prefix with '-' for descending order, otherwise ascending.
21
+
22
+ Args:
23
+ params: Dictionary with query parameters (e.g., {'sort': '-updated_at'} or {'sort': ['-updated_at', 'created_at']})
24
+
25
+ Returns:
26
+ List of SortOption objects
27
+
28
+ Examples:
29
+ >>> parse_sort_params({'sort': '-updated_at'})
30
+ [SortOption(field='updated_at', order='desc')]
31
+ >>> parse_sort_params({'sort': ['-updated_at', 'created_at']})
32
+ [SortOption(field='updated_at', order='desc'), SortOption(field='created_at', order='asc')]
33
+ """
34
+ sort_options: List[SortOption] = []
35
+
36
+ # Get sort parameter (can be string or list)
37
+ sort_param = params.get("sort")
38
+ if not sort_param:
39
+ return sort_options
40
+
41
+ # Normalize to list
42
+ if isinstance(sort_param, str):
43
+ sort_strings = [sort_param]
44
+ elif isinstance(sort_param, list):
45
+ sort_strings = sort_param
46
+ else:
47
+ return sort_options
48
+
49
+ # Parse each sort string
50
+ for sort_str in sort_strings:
51
+ if not isinstance(sort_str, str):
52
+ continue
53
+
54
+ sort_str = sort_str.strip()
55
+ if not sort_str:
56
+ continue
57
+
58
+ # Check for descending order (prefix with '-')
59
+ if sort_str.startswith("-"):
60
+ field = sort_str[1:].strip()
61
+ order: SortOrder = "desc"
62
+ else:
63
+ field = sort_str.strip()
64
+ order = "asc"
65
+
66
+ if field:
67
+ sort_options.append(SortOption(field=field, order=cast(SortOrder, order)))
68
+
69
+ return sort_options
70
+
71
+
72
+ def build_sort_string(sort_options: List[SortOption]) -> str:
73
+ """
74
+ Convert SortOption list to query string format.
75
+
76
+ Converts SortOption objects to sort query string format.
77
+ Descending order fields are prefixed with '-'.
78
+
79
+ Args:
80
+ sort_options: List of SortOption objects
81
+
82
+ Returns:
83
+ Sort query string (e.g., '-updated_at,created_at' or single value '-updated_at')
84
+
85
+ Examples:
86
+ >>> from miso_client.models.sort import SortOption
87
+ >>> sort_options = [SortOption(field='updated_at', order='desc')]
88
+ >>> build_sort_string(sort_options)
89
+ '-updated_at'
90
+ >>> sort_options = [
91
+ ... SortOption(field='updated_at', order='desc'),
92
+ ... SortOption(field='created_at', order='asc')
93
+ ... ]
94
+ >>> build_sort_string(sort_options)
95
+ '-updated_at,created_at'
96
+ """
97
+ if not sort_options:
98
+ return ""
99
+
100
+ sort_strings: List[str] = []
101
+ for sort_option in sort_options:
102
+ field = sort_option.field
103
+ order = sort_option.order
104
+
105
+ # URL encode field name
106
+ field_encoded = quote(field)
107
+
108
+ # Add '-' prefix for descending order
109
+ if order == "desc":
110
+ sort_strings.append(f"-{field_encoded}")
111
+ else:
112
+ sort_strings.append(field_encoded)
113
+
114
+ # Join multiple sorts with comma (if needed for single sort param)
115
+ # Or return as comma-separated string
116
+ return ",".join(sort_strings)
@@ -0,0 +1,114 @@
1
+ """
2
+ Client token utilities for extracting information from JWT tokens.
3
+
4
+ This module provides utilities for decoding client tokens and extracting
5
+ application/environment information without verification.
6
+ """
7
+
8
+ from typing import Dict, Optional
9
+
10
+ from .jwt_tools import decode_token
11
+
12
+
13
+ def extract_client_token_info(client_token: str) -> Dict[str, Optional[str]]:
14
+ """
15
+ Extract application and environment information from client token.
16
+
17
+ Decodes JWT token without verification (no secret available) and extracts
18
+ fields with fallback support for multiple field name variations.
19
+
20
+ Args:
21
+ client_token: JWT client token string
22
+
23
+ Returns:
24
+ Dictionary with optional fields:
25
+ - application: Optional[str] - Application name
26
+ - environment: Optional[str] - Environment name
27
+ - applicationId: Optional[str] - Application ID
28
+ - clientId: Optional[str] - Client ID
29
+
30
+ Example:
31
+ >>> token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
32
+ >>> info = extract_client_token_info(token)
33
+ >>> info.get("application")
34
+ 'my-app'
35
+ """
36
+ if not client_token or not isinstance(client_token, str):
37
+ return {
38
+ "application": None,
39
+ "environment": None,
40
+ "applicationId": None,
41
+ "clientId": None,
42
+ }
43
+
44
+ try:
45
+ decoded = decode_token(client_token)
46
+ if not decoded or not isinstance(decoded, dict):
47
+ return {
48
+ "application": None,
49
+ "environment": None,
50
+ "applicationId": None,
51
+ "clientId": None,
52
+ }
53
+
54
+ # Extract fields with fallback support
55
+ application = (
56
+ decoded.get("application")
57
+ or decoded.get("app")
58
+ or decoded.get("Application")
59
+ or decoded.get("App")
60
+ )
61
+ if isinstance(application, str):
62
+ application = application.strip() if application.strip() else None
63
+ else:
64
+ application = None
65
+
66
+ environment = (
67
+ decoded.get("environment")
68
+ or decoded.get("env")
69
+ or decoded.get("Environment")
70
+ or decoded.get("Env")
71
+ )
72
+ if isinstance(environment, str):
73
+ environment = environment.strip() if environment.strip() else None
74
+ else:
75
+ environment = None
76
+
77
+ application_id = (
78
+ decoded.get("applicationId")
79
+ or decoded.get("app_id")
80
+ or decoded.get("application_id")
81
+ or decoded.get("ApplicationId")
82
+ or decoded.get("AppId")
83
+ )
84
+ if isinstance(application_id, str):
85
+ application_id = application_id.strip() if application_id.strip() else None
86
+ else:
87
+ application_id = None
88
+
89
+ client_id = (
90
+ decoded.get("clientId")
91
+ or decoded.get("client_id")
92
+ or decoded.get("ClientId")
93
+ or decoded.get("Client_Id")
94
+ )
95
+ if isinstance(client_id, str):
96
+ client_id = client_id.strip() if client_id.strip() else None
97
+ else:
98
+ client_id = None
99
+
100
+ return {
101
+ "application": application,
102
+ "environment": environment,
103
+ "applicationId": application_id,
104
+ "clientId": client_id,
105
+ }
106
+
107
+ except Exception:
108
+ # Decode failed, return empty dict
109
+ return {
110
+ "application": None,
111
+ "environment": None,
112
+ "applicationId": None,
113
+ "clientId": None,
114
+ }
@@ -0,0 +1,66 @@
1
+ """
2
+ URL validation utility for controller URLs.
3
+
4
+ This module provides utilities for validating HTTP/HTTPS URLs with comprehensive
5
+ checks to prevent dangerous protocols and ensure valid URL structure.
6
+ """
7
+
8
+ from urllib.parse import urlparse
9
+
10
+
11
+ def validate_url(url: str) -> bool:
12
+ """
13
+ Validate HTTP/HTTPS URL with comprehensive checks.
14
+
15
+ Validates that the URL:
16
+ - Starts with http:// or https://
17
+ - Has a valid hostname
18
+ - Does not use dangerous protocols (javascript:, data:, etc.)
19
+
20
+ Args:
21
+ url: URL string to validate
22
+
23
+ Returns:
24
+ True if URL is valid, False otherwise
25
+
26
+ Example:
27
+ >>> validate_url("https://controller.example.com")
28
+ True
29
+ >>> validate_url("javascript:alert('xss')")
30
+ False
31
+ >>> validate_url("http://localhost:3000")
32
+ True
33
+ """
34
+ if not url or not isinstance(url, str):
35
+ return False
36
+
37
+ url = url.strip()
38
+ if not url:
39
+ return False
40
+
41
+ # Check for dangerous protocols
42
+ dangerous_protocols = ["javascript:", "data:", "vbscript:", "file:", "about:"]
43
+ url_lower = url.lower()
44
+ for protocol in dangerous_protocols:
45
+ if url_lower.startswith(protocol):
46
+ return False
47
+
48
+ # Must start with http:// or https://
49
+ if not url_lower.startswith(("http://", "https://")):
50
+ return False
51
+
52
+ try:
53
+ parsed = urlparse(url)
54
+ # Must have a valid hostname (netloc)
55
+ if not parsed.netloc:
56
+ return False
57
+
58
+ # Hostname must not be empty
59
+ hostname = parsed.netloc.split(":")[0] # Remove port if present
60
+ if not hostname:
61
+ return False
62
+
63
+ return True
64
+ except Exception:
65
+ # URL parsing failed
66
+ return False