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.
- miso_client/__init__.py +523 -130
- miso_client/api/__init__.py +35 -0
- miso_client/api/auth_api.py +367 -0
- miso_client/api/logs_api.py +91 -0
- miso_client/api/permissions_api.py +88 -0
- miso_client/api/roles_api.py +88 -0
- miso_client/api/types/__init__.py +75 -0
- miso_client/api/types/auth_types.py +183 -0
- miso_client/api/types/logs_types.py +71 -0
- miso_client/api/types/permissions_types.py +31 -0
- miso_client/api/types/roles_types.py +31 -0
- miso_client/errors.py +30 -4
- miso_client/models/__init__.py +4 -0
- miso_client/models/config.py +275 -72
- miso_client/models/error_response.py +39 -0
- miso_client/models/filter.py +255 -0
- miso_client/models/pagination.py +44 -0
- miso_client/models/sort.py +25 -0
- miso_client/services/__init__.py +6 -5
- miso_client/services/auth.py +496 -87
- miso_client/services/cache.py +42 -41
- miso_client/services/encryption.py +18 -17
- miso_client/services/logger.py +467 -328
- miso_client/services/logger_chain.py +288 -0
- miso_client/services/permission.py +130 -67
- miso_client/services/redis.py +28 -23
- miso_client/services/role.py +145 -62
- miso_client/utils/__init__.py +3 -3
- miso_client/utils/audit_log_queue.py +222 -0
- miso_client/utils/auth_strategy.py +88 -0
- miso_client/utils/auth_utils.py +65 -0
- miso_client/utils/circuit_breaker.py +125 -0
- miso_client/utils/client_token_manager.py +244 -0
- miso_client/utils/config_loader.py +88 -17
- miso_client/utils/controller_url_resolver.py +80 -0
- miso_client/utils/data_masker.py +104 -33
- miso_client/utils/environment_token.py +126 -0
- miso_client/utils/error_utils.py +216 -0
- miso_client/utils/fastapi_endpoints.py +166 -0
- miso_client/utils/filter.py +364 -0
- miso_client/utils/filter_applier.py +143 -0
- miso_client/utils/filter_parser.py +110 -0
- miso_client/utils/flask_endpoints.py +169 -0
- miso_client/utils/http_client.py +494 -262
- miso_client/utils/http_client_logging.py +352 -0
- miso_client/utils/http_client_logging_helpers.py +197 -0
- miso_client/utils/http_client_query_helpers.py +138 -0
- miso_client/utils/http_error_handler.py +92 -0
- miso_client/utils/http_log_formatter.py +115 -0
- miso_client/utils/http_log_masker.py +203 -0
- miso_client/utils/internal_http_client.py +435 -0
- miso_client/utils/jwt_tools.py +125 -16
- miso_client/utils/logger_helpers.py +206 -0
- miso_client/utils/logging_helpers.py +70 -0
- miso_client/utils/origin_validator.py +128 -0
- miso_client/utils/pagination.py +275 -0
- miso_client/utils/request_context.py +285 -0
- miso_client/utils/sensitive_fields_loader.py +116 -0
- miso_client/utils/sort.py +116 -0
- miso_client/utils/token_utils.py +114 -0
- miso_client/utils/url_validator.py +66 -0
- miso_client/utils/user_token_refresh.py +245 -0
- miso_client-3.7.2.dist-info/METADATA +1021 -0
- miso_client-3.7.2.dist-info/RECORD +68 -0
- miso_client-0.1.0.dist-info/METADATA +0 -551
- miso_client-0.1.0.dist-info/RECORD +0 -23
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/WHEEL +0 -0
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/licenses/LICENSE +0 -0
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internal HTTP client utility for controller communication.
|
|
3
|
+
|
|
4
|
+
This module provides the internal HTTP client implementation with automatic client
|
|
5
|
+
token management. This class is not meant to be used directly - use the public
|
|
6
|
+
HttpClient class instead which adds ISO 27001 compliant audit and debug logging.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, Literal, Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ..errors import AuthenticationError, ConnectionError, MisoClientError
|
|
14
|
+
from ..models.config import AuthStrategy, MisoClientConfig
|
|
15
|
+
from .auth_strategy import AuthStrategyHandler
|
|
16
|
+
from .client_token_manager import ClientTokenManager
|
|
17
|
+
from .controller_url_resolver import resolve_controller_url
|
|
18
|
+
from .http_error_handler import parse_error_response
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InternalHttpClient:
|
|
22
|
+
"""
|
|
23
|
+
Internal HTTP client for Miso Controller communication with automatic client token management.
|
|
24
|
+
|
|
25
|
+
This class contains the core HTTP functionality without logging.
|
|
26
|
+
It is wrapped by the public HttpClient class which adds audit and debug logging.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: MisoClientConfig):
|
|
30
|
+
"""
|
|
31
|
+
Initialize internal HTTP client with configuration.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
config: MisoClient configuration
|
|
35
|
+
"""
|
|
36
|
+
self.config = config
|
|
37
|
+
self.client: Optional[httpx.AsyncClient] = None
|
|
38
|
+
self.token_manager = ClientTokenManager(config)
|
|
39
|
+
|
|
40
|
+
async def _initialize_client(self):
|
|
41
|
+
"""Initialize HTTP client if not already initialized."""
|
|
42
|
+
if self.client is None:
|
|
43
|
+
# Use resolved URL (controllerPrivateUrl or controller_url)
|
|
44
|
+
resolved_url = resolve_controller_url(self.config)
|
|
45
|
+
self.client = httpx.AsyncClient(
|
|
46
|
+
base_url=resolved_url,
|
|
47
|
+
timeout=30.0,
|
|
48
|
+
headers={
|
|
49
|
+
"Content-Type": "application/json",
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def _ensure_client_token(self):
|
|
54
|
+
"""Ensure client token is set in headers."""
|
|
55
|
+
await self._initialize_client()
|
|
56
|
+
token = await self.token_manager.get_client_token()
|
|
57
|
+
if self.client:
|
|
58
|
+
self.client.headers["x-client-token"] = token
|
|
59
|
+
|
|
60
|
+
async def close(self):
|
|
61
|
+
"""Close the HTTP client."""
|
|
62
|
+
if self.client:
|
|
63
|
+
await self.client.aclose()
|
|
64
|
+
self.client = None
|
|
65
|
+
|
|
66
|
+
async def __aenter__(self):
|
|
67
|
+
"""Async context manager entry."""
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
71
|
+
"""Async context manager exit."""
|
|
72
|
+
await self.close()
|
|
73
|
+
|
|
74
|
+
async def get(self, url: str, **kwargs) -> Any:
|
|
75
|
+
"""
|
|
76
|
+
Make GET request.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
url: Request URL
|
|
80
|
+
**kwargs: Additional httpx request parameters
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Response data (JSON parsed)
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
MisoClientError: If request fails
|
|
87
|
+
"""
|
|
88
|
+
await self._initialize_client()
|
|
89
|
+
await self._ensure_client_token()
|
|
90
|
+
try:
|
|
91
|
+
assert self.client is not None
|
|
92
|
+
response = await self.client.get(url, **kwargs)
|
|
93
|
+
|
|
94
|
+
# Handle 401 - clear token to force refresh
|
|
95
|
+
if response.status_code == 401:
|
|
96
|
+
self.token_manager.clear_token()
|
|
97
|
+
|
|
98
|
+
response.raise_for_status()
|
|
99
|
+
return response.json()
|
|
100
|
+
except httpx.HTTPStatusError as e:
|
|
101
|
+
# Try to parse structured error response
|
|
102
|
+
error_response = parse_error_response(e.response, url)
|
|
103
|
+
error_body = {}
|
|
104
|
+
if (
|
|
105
|
+
e.response.headers.get("content-type", "").startswith("application/json")
|
|
106
|
+
and not error_response
|
|
107
|
+
):
|
|
108
|
+
try:
|
|
109
|
+
error_body = e.response.json()
|
|
110
|
+
except (ValueError, TypeError):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
raise MisoClientError(
|
|
114
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
115
|
+
status_code=e.response.status_code,
|
|
116
|
+
error_body=error_body,
|
|
117
|
+
error_response=error_response,
|
|
118
|
+
)
|
|
119
|
+
except httpx.RequestError as e:
|
|
120
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
121
|
+
|
|
122
|
+
async def post(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
|
|
123
|
+
"""
|
|
124
|
+
Make POST request.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
url: Request URL
|
|
128
|
+
data: Request data (will be JSON encoded)
|
|
129
|
+
**kwargs: Additional httpx request parameters
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Response data (JSON parsed)
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
MisoClientError: If request fails
|
|
136
|
+
"""
|
|
137
|
+
await self._initialize_client()
|
|
138
|
+
await self._ensure_client_token()
|
|
139
|
+
try:
|
|
140
|
+
assert self.client is not None
|
|
141
|
+
response = await self.client.post(url, json=data, **kwargs)
|
|
142
|
+
|
|
143
|
+
if response.status_code == 401:
|
|
144
|
+
self.token_manager.clear_token()
|
|
145
|
+
|
|
146
|
+
response.raise_for_status()
|
|
147
|
+
return response.json()
|
|
148
|
+
except httpx.HTTPStatusError as e:
|
|
149
|
+
# Try to parse structured error response
|
|
150
|
+
error_response = parse_error_response(e.response, url)
|
|
151
|
+
error_body = {}
|
|
152
|
+
if (
|
|
153
|
+
e.response.headers.get("content-type", "").startswith("application/json")
|
|
154
|
+
and not error_response
|
|
155
|
+
):
|
|
156
|
+
try:
|
|
157
|
+
error_body = e.response.json()
|
|
158
|
+
except (ValueError, TypeError):
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
raise MisoClientError(
|
|
162
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
163
|
+
status_code=e.response.status_code,
|
|
164
|
+
error_body=error_body,
|
|
165
|
+
error_response=error_response,
|
|
166
|
+
)
|
|
167
|
+
except httpx.RequestError as e:
|
|
168
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
169
|
+
|
|
170
|
+
async def put(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
|
|
171
|
+
"""
|
|
172
|
+
Make PUT request.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
url: Request URL
|
|
176
|
+
data: Request data (will be JSON encoded)
|
|
177
|
+
**kwargs: Additional httpx request parameters
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Response data (JSON parsed)
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
MisoClientError: If request fails
|
|
184
|
+
"""
|
|
185
|
+
await self._initialize_client()
|
|
186
|
+
await self._ensure_client_token()
|
|
187
|
+
try:
|
|
188
|
+
assert self.client is not None
|
|
189
|
+
response = await self.client.put(url, json=data, **kwargs)
|
|
190
|
+
|
|
191
|
+
if response.status_code == 401:
|
|
192
|
+
self.token_manager.clear_token()
|
|
193
|
+
|
|
194
|
+
response.raise_for_status()
|
|
195
|
+
return response.json()
|
|
196
|
+
except httpx.HTTPStatusError as e:
|
|
197
|
+
# Try to parse structured error response
|
|
198
|
+
error_response = parse_error_response(e.response, url)
|
|
199
|
+
error_body = {}
|
|
200
|
+
if (
|
|
201
|
+
e.response.headers.get("content-type", "").startswith("application/json")
|
|
202
|
+
and not error_response
|
|
203
|
+
):
|
|
204
|
+
try:
|
|
205
|
+
error_body = e.response.json()
|
|
206
|
+
except (ValueError, TypeError):
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
raise MisoClientError(
|
|
210
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
211
|
+
status_code=e.response.status_code,
|
|
212
|
+
error_body=error_body,
|
|
213
|
+
error_response=error_response,
|
|
214
|
+
)
|
|
215
|
+
except httpx.RequestError as e:
|
|
216
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
217
|
+
|
|
218
|
+
async def delete(self, url: str, **kwargs) -> Any:
|
|
219
|
+
"""
|
|
220
|
+
Make DELETE request.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
url: Request URL
|
|
224
|
+
**kwargs: Additional httpx request parameters
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Response data (JSON parsed)
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
MisoClientError: If request fails
|
|
231
|
+
"""
|
|
232
|
+
await self._initialize_client()
|
|
233
|
+
await self._ensure_client_token()
|
|
234
|
+
try:
|
|
235
|
+
assert self.client is not None
|
|
236
|
+
response = await self.client.delete(url, **kwargs)
|
|
237
|
+
|
|
238
|
+
if response.status_code == 401:
|
|
239
|
+
self.token_manager.clear_token()
|
|
240
|
+
|
|
241
|
+
response.raise_for_status()
|
|
242
|
+
return response.json()
|
|
243
|
+
except httpx.HTTPStatusError as e:
|
|
244
|
+
# Try to parse structured error response
|
|
245
|
+
error_response = parse_error_response(e.response, url)
|
|
246
|
+
error_body = {}
|
|
247
|
+
if (
|
|
248
|
+
e.response.headers.get("content-type", "").startswith("application/json")
|
|
249
|
+
and not error_response
|
|
250
|
+
):
|
|
251
|
+
try:
|
|
252
|
+
error_body = e.response.json()
|
|
253
|
+
except (ValueError, TypeError):
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
raise MisoClientError(
|
|
257
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
258
|
+
status_code=e.response.status_code,
|
|
259
|
+
error_body=error_body,
|
|
260
|
+
error_response=error_response,
|
|
261
|
+
)
|
|
262
|
+
except httpx.RequestError as e:
|
|
263
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
264
|
+
|
|
265
|
+
async def request(
|
|
266
|
+
self,
|
|
267
|
+
method: Literal["GET", "POST", "PUT", "DELETE"],
|
|
268
|
+
url: str,
|
|
269
|
+
data: Optional[Dict[str, Any]] = None,
|
|
270
|
+
**kwargs,
|
|
271
|
+
) -> Any:
|
|
272
|
+
"""
|
|
273
|
+
Generic request method.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
method: HTTP method
|
|
277
|
+
url: Request URL
|
|
278
|
+
data: Request data (for POST/PUT)
|
|
279
|
+
**kwargs: Additional httpx request parameters
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Response data (JSON parsed)
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
MisoClientError: If request fails
|
|
286
|
+
"""
|
|
287
|
+
method_upper = method.upper()
|
|
288
|
+
if method_upper == "GET":
|
|
289
|
+
return await self.get(url, **kwargs)
|
|
290
|
+
elif method_upper == "POST":
|
|
291
|
+
return await self.post(url, data, **kwargs)
|
|
292
|
+
elif method_upper == "PUT":
|
|
293
|
+
return await self.put(url, data, **kwargs)
|
|
294
|
+
elif method_upper == "DELETE":
|
|
295
|
+
return await self.delete(url, **kwargs)
|
|
296
|
+
else:
|
|
297
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
298
|
+
|
|
299
|
+
async def authenticated_request(
|
|
300
|
+
self,
|
|
301
|
+
method: Literal["GET", "POST", "PUT", "DELETE"],
|
|
302
|
+
url: str,
|
|
303
|
+
token: str,
|
|
304
|
+
data: Optional[Dict[str, Any]] = None,
|
|
305
|
+
auth_strategy: Optional[AuthStrategy] = None,
|
|
306
|
+
**kwargs,
|
|
307
|
+
) -> Any:
|
|
308
|
+
"""
|
|
309
|
+
Make authenticated request with Bearer token.
|
|
310
|
+
|
|
311
|
+
IMPORTANT: Client token is sent as x-client-token header (via _ensure_client_token)
|
|
312
|
+
User token is sent as Authorization: Bearer header (this method parameter)
|
|
313
|
+
These are two separate tokens for different purposes.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
method: HTTP method
|
|
317
|
+
url: Request URL
|
|
318
|
+
token: User authentication token (sent as Bearer token)
|
|
319
|
+
data: Request data (for POST/PUT)
|
|
320
|
+
auth_strategy: Optional authentication strategy (defaults to bearer + client-token)
|
|
321
|
+
**kwargs: Additional httpx request parameters
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Response data (JSON parsed)
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
MisoClientError: If request fails
|
|
328
|
+
"""
|
|
329
|
+
# If no strategy provided, use default (backward compatibility)
|
|
330
|
+
if auth_strategy is None:
|
|
331
|
+
auth_strategy = AuthStrategyHandler.get_default_strategy()
|
|
332
|
+
# Set bearer token from parameter
|
|
333
|
+
auth_strategy.bearerToken = token
|
|
334
|
+
|
|
335
|
+
# Use request_with_auth_strategy for consistency
|
|
336
|
+
return await self.request_with_auth_strategy(method, url, auth_strategy, data, **kwargs)
|
|
337
|
+
|
|
338
|
+
async def request_with_auth_strategy(
|
|
339
|
+
self,
|
|
340
|
+
method: Literal["GET", "POST", "PUT", "DELETE"],
|
|
341
|
+
url: str,
|
|
342
|
+
auth_strategy: AuthStrategy,
|
|
343
|
+
data: Optional[Dict[str, Any]] = None,
|
|
344
|
+
**kwargs,
|
|
345
|
+
) -> Any:
|
|
346
|
+
"""
|
|
347
|
+
Make request with authentication strategy (priority-based fallback).
|
|
348
|
+
|
|
349
|
+
Tries authentication methods in priority order until one succeeds.
|
|
350
|
+
If a method returns 401, automatically tries the next method in the strategy.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
method: HTTP method
|
|
354
|
+
url: Request URL
|
|
355
|
+
auth_strategy: Authentication strategy configuration
|
|
356
|
+
data: Request data (for POST/PUT)
|
|
357
|
+
**kwargs: Additional httpx request parameters
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Response data (JSON parsed)
|
|
361
|
+
|
|
362
|
+
Raises:
|
|
363
|
+
MisoClientError: If all authentication methods fail
|
|
364
|
+
"""
|
|
365
|
+
await self._initialize_client()
|
|
366
|
+
|
|
367
|
+
# Get client token once (used by client-token and client-credentials methods)
|
|
368
|
+
# Client token is always sent (identifies the application)
|
|
369
|
+
client_token: Optional[str] = None
|
|
370
|
+
if "client-token" in auth_strategy.methods or "client-credentials" in auth_strategy.methods:
|
|
371
|
+
client_token = await self.token_manager.get_client_token()
|
|
372
|
+
|
|
373
|
+
# Try each method in priority order
|
|
374
|
+
last_error: Optional[Exception] = None
|
|
375
|
+
for auth_method in auth_strategy.methods:
|
|
376
|
+
try:
|
|
377
|
+
# Build headers for this auth method
|
|
378
|
+
auth_headers = AuthStrategyHandler.build_auth_headers(
|
|
379
|
+
auth_method, auth_strategy, client_token
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Merge with existing headers
|
|
383
|
+
request_headers = kwargs.get("headers", {}).copy()
|
|
384
|
+
request_headers.update(auth_headers)
|
|
385
|
+
request_kwargs = {**kwargs, "headers": request_headers}
|
|
386
|
+
|
|
387
|
+
# Make the request using existing request method
|
|
388
|
+
# Note: request() will call _ensure_client_token() which always sends client token
|
|
389
|
+
try:
|
|
390
|
+
return await self.request(method, url, data, **request_kwargs)
|
|
391
|
+
except httpx.HTTPStatusError as e:
|
|
392
|
+
# If 401, try next method
|
|
393
|
+
if e.response.status_code == 401:
|
|
394
|
+
# Clear client token to force refresh on next attempt
|
|
395
|
+
if auth_method in ["client-token", "client-credentials"]:
|
|
396
|
+
self.token_manager.clear_token()
|
|
397
|
+
last_error = e
|
|
398
|
+
continue
|
|
399
|
+
# For other HTTP errors, re-raise (don't try next method)
|
|
400
|
+
raise
|
|
401
|
+
except httpx.RequestError as e:
|
|
402
|
+
# Connection errors - don't retry with different auth
|
|
403
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
404
|
+
|
|
405
|
+
except ValueError as e:
|
|
406
|
+
# Missing credentials for this method - try next
|
|
407
|
+
last_error = e
|
|
408
|
+
continue
|
|
409
|
+
except (ConnectionError, MisoClientError):
|
|
410
|
+
# Don't retry connection errors or non-401 client errors
|
|
411
|
+
raise
|
|
412
|
+
|
|
413
|
+
# All methods failed
|
|
414
|
+
if last_error:
|
|
415
|
+
status_code = getattr(last_error, "status_code", 401)
|
|
416
|
+
error_response = None
|
|
417
|
+
if hasattr(last_error, "error_response"):
|
|
418
|
+
error_response = last_error.error_response
|
|
419
|
+
raise MisoClientError(
|
|
420
|
+
f"All authentication methods failed. Last error: {str(last_error)}",
|
|
421
|
+
status_code=status_code,
|
|
422
|
+
error_response=error_response,
|
|
423
|
+
)
|
|
424
|
+
raise AuthenticationError("No authentication methods available")
|
|
425
|
+
|
|
426
|
+
async def get_environment_token(self) -> str:
|
|
427
|
+
"""
|
|
428
|
+
Get environment token using client credentials.
|
|
429
|
+
|
|
430
|
+
This is called automatically by HttpClient but can be called manually.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
Client token string
|
|
434
|
+
"""
|
|
435
|
+
return await self.token_manager.get_client_token()
|
miso_client/utils/jwt_tools.py
CHANGED
|
@@ -3,23 +3,26 @@ JWT token utilities for safe decoding without verification.
|
|
|
3
3
|
|
|
4
4
|
This module provides utilities for extracting information from JWT tokens
|
|
5
5
|
without verification, used for cache optimization and context extraction.
|
|
6
|
+
Includes JWT token caching for performance optimization.
|
|
6
7
|
"""
|
|
7
8
|
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from typing import Any, Dict, Optional, Tuple, cast
|
|
11
|
+
|
|
8
12
|
import jwt
|
|
9
|
-
from typing import Optional, Dict, Any, cast
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
|
13
16
|
"""
|
|
14
17
|
Safely decode JWT token without verification.
|
|
15
|
-
|
|
18
|
+
|
|
16
19
|
This is used for extracting user information (like userId) from tokens
|
|
17
20
|
for cache optimization. The token is NOT verified - it should only be
|
|
18
21
|
used for cache key generation, not for authentication decisions.
|
|
19
|
-
|
|
22
|
+
|
|
20
23
|
Args:
|
|
21
24
|
token: JWT token string
|
|
22
|
-
|
|
25
|
+
|
|
23
26
|
Returns:
|
|
24
27
|
Decoded token payload as dictionary, or None if decoding fails
|
|
25
28
|
"""
|
|
@@ -35,44 +38,150 @@ def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
|
|
35
38
|
def extract_user_id(token: str) -> Optional[str]:
|
|
36
39
|
"""
|
|
37
40
|
Extract user ID from JWT token.
|
|
38
|
-
|
|
41
|
+
|
|
39
42
|
Tries common JWT claim fields: sub, userId, user_id, id
|
|
40
|
-
|
|
43
|
+
|
|
41
44
|
Args:
|
|
42
45
|
token: JWT token string
|
|
43
|
-
|
|
46
|
+
|
|
44
47
|
Returns:
|
|
45
48
|
User ID string if found, None otherwise
|
|
46
49
|
"""
|
|
47
50
|
decoded = decode_token(token)
|
|
48
51
|
if not decoded:
|
|
49
52
|
return None
|
|
50
|
-
|
|
53
|
+
|
|
51
54
|
# Try common JWT claim fields for user ID
|
|
52
55
|
user_id = (
|
|
53
|
-
decoded.get("sub") or
|
|
54
|
-
decoded.get("userId") or
|
|
55
|
-
decoded.get("user_id") or
|
|
56
|
-
decoded.get("id")
|
|
56
|
+
decoded.get("sub") or decoded.get("userId") or decoded.get("user_id") or decoded.get("id")
|
|
57
57
|
)
|
|
58
|
-
|
|
58
|
+
|
|
59
59
|
return str(user_id) if user_id else None
|
|
60
60
|
|
|
61
61
|
|
|
62
62
|
def extract_session_id(token: str) -> Optional[str]:
|
|
63
63
|
"""
|
|
64
64
|
Extract session ID from JWT token.
|
|
65
|
-
|
|
65
|
+
|
|
66
66
|
Args:
|
|
67
67
|
token: JWT token string
|
|
68
|
-
|
|
68
|
+
|
|
69
69
|
Returns:
|
|
70
70
|
Session ID string if found, None otherwise
|
|
71
71
|
"""
|
|
72
72
|
decoded = decode_token(token)
|
|
73
73
|
if not decoded:
|
|
74
74
|
return None
|
|
75
|
-
|
|
75
|
+
|
|
76
76
|
value = decoded.get("sid") or decoded.get("sessionId")
|
|
77
77
|
return value if isinstance(value, str) else None
|
|
78
78
|
|
|
79
|
+
|
|
80
|
+
class JwtTokenCache:
|
|
81
|
+
"""
|
|
82
|
+
JWT token cache with expiration tracking.
|
|
83
|
+
|
|
84
|
+
Caches decoded JWT tokens to avoid repeated decoding operations.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, max_size: int = 1000):
|
|
88
|
+
"""
|
|
89
|
+
Initialize JWT token cache.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
max_size: Maximum cache size to prevent memory leaks
|
|
93
|
+
"""
|
|
94
|
+
self._cache: Dict[str, Tuple[Dict[str, Any], datetime]] = {}
|
|
95
|
+
self._max_size = max_size
|
|
96
|
+
|
|
97
|
+
def get_decoded_token(self, token: str) -> Optional[Dict[str, Any]]:
|
|
98
|
+
"""
|
|
99
|
+
Get decoded JWT token with caching for performance optimization.
|
|
100
|
+
|
|
101
|
+
Tokens are cached with expiration tracking to avoid repeated decoding.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
token: JWT token string
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Decoded token payload as dictionary, or None if decoding fails
|
|
108
|
+
"""
|
|
109
|
+
now = datetime.now()
|
|
110
|
+
|
|
111
|
+
# Check cache first
|
|
112
|
+
if token in self._cache:
|
|
113
|
+
cached_decoded, expires_at = self._cache[token]
|
|
114
|
+
# If not expired, return cached value
|
|
115
|
+
if expires_at > now:
|
|
116
|
+
return cached_decoded
|
|
117
|
+
# Expired, remove from cache
|
|
118
|
+
del self._cache[token]
|
|
119
|
+
|
|
120
|
+
# Decode token
|
|
121
|
+
try:
|
|
122
|
+
decoded = decode_token(token)
|
|
123
|
+
if not decoded:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
# Extract expiration from token (if available)
|
|
127
|
+
expires_at = now + timedelta(hours=1) # Default: 1 hour cache
|
|
128
|
+
if "exp" in decoded and isinstance(decoded["exp"], (int, float)):
|
|
129
|
+
# Use token expiration minus 5 minutes buffer
|
|
130
|
+
token_exp = datetime.fromtimestamp(decoded["exp"])
|
|
131
|
+
expires_at = min(token_exp - timedelta(minutes=5), now + timedelta(hours=1))
|
|
132
|
+
elif "iat" in decoded and "exp" not in decoded:
|
|
133
|
+
# Estimate expiration if only issued_at is present
|
|
134
|
+
expires_at = now + timedelta(hours=1)
|
|
135
|
+
|
|
136
|
+
# Cache the decoded token
|
|
137
|
+
# Limit cache size to prevent memory leaks
|
|
138
|
+
if len(self._cache) >= self._max_size:
|
|
139
|
+
# Remove oldest entries (simple FIFO - remove first 10%)
|
|
140
|
+
keys_to_remove = list(self._cache.keys())[: self._max_size // 10]
|
|
141
|
+
for key in keys_to_remove:
|
|
142
|
+
del self._cache[key]
|
|
143
|
+
|
|
144
|
+
self._cache[token] = (decoded, expires_at)
|
|
145
|
+
return decoded
|
|
146
|
+
|
|
147
|
+
except Exception:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def extract_user_id_from_headers(self, headers: Dict[str, Any]) -> Optional[str]:
|
|
151
|
+
"""
|
|
152
|
+
Extract user ID from JWT token in Authorization header with caching.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
headers: Request headers dictionary
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
User ID if found, None otherwise
|
|
159
|
+
"""
|
|
160
|
+
auth_header = headers.get("authorization") or headers.get("Authorization")
|
|
161
|
+
if not auth_header or not isinstance(auth_header, str):
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
# Extract token (Bearer <token> format)
|
|
165
|
+
if auth_header.startswith("Bearer "):
|
|
166
|
+
token = auth_header[7:]
|
|
167
|
+
else:
|
|
168
|
+
token = auth_header
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
decoded = self.get_decoded_token(token)
|
|
172
|
+
if decoded:
|
|
173
|
+
return decoded.get("sub") or decoded.get("userId") or decoded.get("user_id")
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
def clear_token(self, token: str) -> None:
|
|
180
|
+
"""
|
|
181
|
+
Clear a specific token from cache.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
token: JWT token string to remove from cache
|
|
185
|
+
"""
|
|
186
|
+
if token in self._cache:
|
|
187
|
+
del self._cache[token]
|