miso-client 0.1.0__py3-none-any.whl → 0.2.0__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.
Potentially problematic release.
This version of miso-client might be problematic. Click here for more details.
- miso_client/__init__.py +83 -81
- miso_client/errors.py +9 -4
- miso_client/models/config.py +56 -35
- miso_client/services/__init__.py +5 -5
- miso_client/services/auth.py +65 -48
- miso_client/services/cache.py +42 -41
- miso_client/services/encryption.py +18 -17
- miso_client/services/logger.py +109 -95
- miso_client/services/permission.py +27 -36
- miso_client/services/redis.py +17 -15
- miso_client/services/role.py +25 -36
- miso_client/utils/__init__.py +3 -3
- miso_client/utils/config_loader.py +24 -16
- miso_client/utils/data_masker.py +27 -28
- miso_client/utils/http_client.py +91 -81
- miso_client/utils/jwt_tools.py +14 -17
- {miso_client-0.1.0.dist-info → miso_client-0.2.0.dist-info}/METADATA +37 -1
- miso_client-0.2.0.dist-info/RECORD +23 -0
- miso_client-0.1.0.dist-info/RECORD +0 -23
- {miso_client-0.1.0.dist-info → miso_client-0.2.0.dist-info}/WHEEL +0 -0
- {miso_client-0.1.0.dist-info → miso_client-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {miso_client-0.1.0.dist-info → miso_client-0.2.0.dist-info}/top_level.txt +0 -0
miso_client/utils/data_masker.py
CHANGED
|
@@ -10,9 +10,9 @@ from typing import Any, Set
|
|
|
10
10
|
|
|
11
11
|
class DataMasker:
|
|
12
12
|
"""Static class for masking sensitive data."""
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
MASKED_VALUE = "***MASKED***"
|
|
15
|
-
|
|
15
|
+
|
|
16
16
|
# Set of sensitive field names (normalized)
|
|
17
17
|
_sensitive_fields: Set[str] = {
|
|
18
18
|
"password",
|
|
@@ -37,58 +37,58 @@ class DataMasker:
|
|
|
37
37
|
"privatekey",
|
|
38
38
|
"secretkey",
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
|
|
41
41
|
@classmethod
|
|
42
42
|
def is_sensitive_field(cls, key: str) -> bool:
|
|
43
43
|
"""
|
|
44
44
|
Check if a field name indicates sensitive data.
|
|
45
|
-
|
|
45
|
+
|
|
46
46
|
Args:
|
|
47
47
|
key: Field name to check
|
|
48
|
-
|
|
48
|
+
|
|
49
49
|
Returns:
|
|
50
50
|
True if field is sensitive, False otherwise
|
|
51
51
|
"""
|
|
52
52
|
# Normalize key: lowercase and remove underscores/hyphens
|
|
53
53
|
normalized_key = key.lower().replace("_", "").replace("-", "")
|
|
54
|
-
|
|
54
|
+
|
|
55
55
|
# Check exact match
|
|
56
56
|
if normalized_key in cls._sensitive_fields:
|
|
57
57
|
return True
|
|
58
|
-
|
|
58
|
+
|
|
59
59
|
# Check if field contains sensitive keywords
|
|
60
60
|
for sensitive_field in cls._sensitive_fields:
|
|
61
61
|
if sensitive_field in normalized_key:
|
|
62
62
|
return True
|
|
63
|
-
|
|
63
|
+
|
|
64
64
|
return False
|
|
65
|
-
|
|
65
|
+
|
|
66
66
|
@classmethod
|
|
67
67
|
def mask_sensitive_data(cls, data: Any) -> Any:
|
|
68
68
|
"""
|
|
69
69
|
Mask sensitive data in objects, arrays, or primitives.
|
|
70
|
-
|
|
70
|
+
|
|
71
71
|
Returns a masked copy without modifying the original.
|
|
72
72
|
Recursively processes nested objects and arrays.
|
|
73
|
-
|
|
73
|
+
|
|
74
74
|
Args:
|
|
75
75
|
data: Data to mask (dict, list, or primitive)
|
|
76
|
-
|
|
76
|
+
|
|
77
77
|
Returns:
|
|
78
78
|
Masked copy of the data
|
|
79
79
|
"""
|
|
80
80
|
# Handle null and undefined
|
|
81
81
|
if data is None:
|
|
82
82
|
return data
|
|
83
|
-
|
|
83
|
+
|
|
84
84
|
# Handle primitives (string, number, boolean)
|
|
85
85
|
if not isinstance(data, (dict, list)):
|
|
86
86
|
return data
|
|
87
|
-
|
|
87
|
+
|
|
88
88
|
# Handle arrays
|
|
89
89
|
if isinstance(data, list):
|
|
90
90
|
return [cls.mask_sensitive_data(item) for item in data]
|
|
91
|
-
|
|
91
|
+
|
|
92
92
|
# Handle objects/dicts
|
|
93
93
|
masked: dict[str, Any] = {}
|
|
94
94
|
for key, value in data.items():
|
|
@@ -101,49 +101,49 @@ class DataMasker:
|
|
|
101
101
|
else:
|
|
102
102
|
# Keep non-sensitive value as-is
|
|
103
103
|
masked[key] = value
|
|
104
|
-
|
|
104
|
+
|
|
105
105
|
return masked
|
|
106
|
-
|
|
106
|
+
|
|
107
107
|
@classmethod
|
|
108
108
|
def mask_value(cls, value: str, show_first: int = 0, show_last: int = 0) -> str:
|
|
109
109
|
"""
|
|
110
110
|
Mask specific value (useful for masking individual strings).
|
|
111
|
-
|
|
111
|
+
|
|
112
112
|
Args:
|
|
113
113
|
value: String value to mask
|
|
114
114
|
show_first: Number of characters to show at the start
|
|
115
115
|
show_last: Number of characters to show at the end
|
|
116
|
-
|
|
116
|
+
|
|
117
117
|
Returns:
|
|
118
118
|
Masked string value
|
|
119
119
|
"""
|
|
120
120
|
if not value or len(value) <= show_first + show_last:
|
|
121
121
|
return cls.MASKED_VALUE
|
|
122
|
-
|
|
122
|
+
|
|
123
123
|
first = value[:show_first] if show_first > 0 else ""
|
|
124
124
|
last = value[-show_last:] if show_last > 0 else ""
|
|
125
125
|
masked_length = max(8, len(value) - show_first - show_last)
|
|
126
126
|
masked = "*" * masked_length
|
|
127
|
-
|
|
127
|
+
|
|
128
128
|
return f"{first}{masked}{last}"
|
|
129
|
-
|
|
129
|
+
|
|
130
130
|
@classmethod
|
|
131
131
|
def contains_sensitive_data(cls, data: Any) -> bool:
|
|
132
132
|
"""
|
|
133
133
|
Check if data contains sensitive information.
|
|
134
|
-
|
|
134
|
+
|
|
135
135
|
Args:
|
|
136
136
|
data: Data to check
|
|
137
|
-
|
|
137
|
+
|
|
138
138
|
Returns:
|
|
139
139
|
True if data contains sensitive fields, False otherwise
|
|
140
140
|
"""
|
|
141
141
|
if data is None or not isinstance(data, (dict, list)):
|
|
142
142
|
return False
|
|
143
|
-
|
|
143
|
+
|
|
144
144
|
if isinstance(data, list):
|
|
145
145
|
return any(cls.contains_sensitive_data(item) for item in data)
|
|
146
|
-
|
|
146
|
+
|
|
147
147
|
# Check object keys
|
|
148
148
|
for key, value in data.items():
|
|
149
149
|
if cls.is_sensitive_field(key):
|
|
@@ -151,6 +151,5 @@ class DataMasker:
|
|
|
151
151
|
if isinstance(value, (dict, list)):
|
|
152
152
|
if cls.contains_sensitive_data(value):
|
|
153
153
|
return True
|
|
154
|
-
|
|
155
|
-
return False
|
|
156
154
|
|
|
155
|
+
return False
|
miso_client/utils/http_client.py
CHANGED
|
@@ -6,21 +6,23 @@ with the Miso Controller, including automatic client token management,
|
|
|
6
6
|
retry logic, and error handling.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
import httpx
|
|
10
9
|
import asyncio
|
|
11
10
|
from datetime import datetime, timedelta
|
|
12
|
-
from typing import Any, Dict,
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
from typing import Any, Dict, Literal, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ..errors import AuthenticationError, ConnectionError, MisoClientError
|
|
16
|
+
from ..models.config import ClientTokenResponse, MisoClientConfig
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
class HttpClient:
|
|
18
20
|
"""HTTP client for Miso Controller communication with automatic client token management."""
|
|
19
|
-
|
|
21
|
+
|
|
20
22
|
def __init__(self, config: MisoClientConfig):
|
|
21
23
|
"""
|
|
22
24
|
Initialize HTTP client with configuration.
|
|
23
|
-
|
|
25
|
+
|
|
24
26
|
Args:
|
|
25
27
|
config: MisoClient configuration
|
|
26
28
|
"""
|
|
@@ -29,7 +31,7 @@ class HttpClient:
|
|
|
29
31
|
self.client_token: Optional[str] = None
|
|
30
32
|
self.token_expires_at: Optional[datetime] = None
|
|
31
33
|
self.token_refresh_lock = asyncio.Lock()
|
|
32
|
-
|
|
34
|
+
|
|
33
35
|
async def _initialize_client(self):
|
|
34
36
|
"""Initialize HTTP client if not already initialized."""
|
|
35
37
|
if self.client is None:
|
|
@@ -38,59 +40,59 @@ class HttpClient:
|
|
|
38
40
|
timeout=30.0,
|
|
39
41
|
headers={
|
|
40
42
|
"Content-Type": "application/json",
|
|
41
|
-
}
|
|
43
|
+
},
|
|
42
44
|
)
|
|
43
|
-
|
|
45
|
+
|
|
44
46
|
async def _get_client_token(self) -> str:
|
|
45
47
|
"""
|
|
46
48
|
Get client token, fetching if needed.
|
|
47
|
-
|
|
49
|
+
|
|
48
50
|
Proactively refreshes if token will expire within 60 seconds.
|
|
49
|
-
|
|
51
|
+
|
|
50
52
|
Returns:
|
|
51
53
|
Client token string
|
|
52
|
-
|
|
54
|
+
|
|
53
55
|
Raises:
|
|
54
56
|
AuthenticationError: If token fetch fails
|
|
55
57
|
"""
|
|
56
58
|
await self._initialize_client()
|
|
57
|
-
|
|
59
|
+
|
|
58
60
|
now = datetime.now()
|
|
59
|
-
|
|
61
|
+
|
|
60
62
|
# If token exists and not expired (with 60s buffer for proactive refresh), return it
|
|
61
63
|
if (
|
|
62
|
-
self.client_token
|
|
63
|
-
self.token_expires_at
|
|
64
|
-
self.token_expires_at > now + timedelta(seconds=60)
|
|
64
|
+
self.client_token
|
|
65
|
+
and self.token_expires_at
|
|
66
|
+
and self.token_expires_at > now + timedelta(seconds=60)
|
|
65
67
|
):
|
|
66
68
|
assert self.client_token is not None
|
|
67
69
|
return self.client_token
|
|
68
|
-
|
|
70
|
+
|
|
69
71
|
# Acquire lock to prevent concurrent token fetches
|
|
70
72
|
async with self.token_refresh_lock:
|
|
71
73
|
# Double-check after acquiring lock
|
|
72
74
|
if (
|
|
73
|
-
self.client_token
|
|
74
|
-
self.token_expires_at
|
|
75
|
-
self.token_expires_at > now + timedelta(seconds=60)
|
|
75
|
+
self.client_token
|
|
76
|
+
and self.token_expires_at
|
|
77
|
+
and self.token_expires_at > now + timedelta(seconds=60)
|
|
76
78
|
):
|
|
77
79
|
assert self.client_token is not None
|
|
78
80
|
return self.client_token
|
|
79
|
-
|
|
81
|
+
|
|
80
82
|
# Fetch new token
|
|
81
83
|
await self._fetch_client_token()
|
|
82
84
|
assert self.client_token is not None
|
|
83
85
|
return self.client_token
|
|
84
|
-
|
|
86
|
+
|
|
85
87
|
async def _fetch_client_token(self) -> None:
|
|
86
88
|
"""
|
|
87
89
|
Fetch client token from controller.
|
|
88
|
-
|
|
90
|
+
|
|
89
91
|
Raises:
|
|
90
92
|
AuthenticationError: If token fetch fails
|
|
91
93
|
"""
|
|
92
94
|
await self._initialize_client()
|
|
93
|
-
|
|
95
|
+
|
|
94
96
|
try:
|
|
95
97
|
# Use a temporary client to avoid interceptor recursion
|
|
96
98
|
temp_client = httpx.AsyncClient(
|
|
@@ -100,67 +102,67 @@ class HttpClient:
|
|
|
100
102
|
"Content-Type": "application/json",
|
|
101
103
|
"x-client-id": self.config.client_id,
|
|
102
104
|
"x-client-secret": self.config.client_secret,
|
|
103
|
-
}
|
|
105
|
+
},
|
|
104
106
|
)
|
|
105
|
-
|
|
107
|
+
|
|
106
108
|
response = await temp_client.post("/api/auth/token")
|
|
107
109
|
await temp_client.aclose()
|
|
108
|
-
|
|
110
|
+
|
|
109
111
|
if response.status_code != 200:
|
|
110
112
|
raise AuthenticationError(
|
|
111
113
|
f"Failed to get client token: HTTP {response.status_code}",
|
|
112
|
-
status_code=response.status_code
|
|
114
|
+
status_code=response.status_code,
|
|
113
115
|
)
|
|
114
|
-
|
|
116
|
+
|
|
115
117
|
data = response.json()
|
|
116
118
|
token_response = ClientTokenResponse(**data)
|
|
117
|
-
|
|
119
|
+
|
|
118
120
|
if not token_response.success or not token_response.token:
|
|
119
121
|
raise AuthenticationError("Failed to get client token: Invalid response")
|
|
120
|
-
|
|
122
|
+
|
|
121
123
|
self.client_token = token_response.token
|
|
122
124
|
# Set expiration with 30 second buffer before actual expiration
|
|
123
125
|
expires_in = max(0, token_response.expiresIn - 30)
|
|
124
126
|
self.token_expires_at = datetime.now() + timedelta(seconds=expires_in)
|
|
125
|
-
|
|
127
|
+
|
|
126
128
|
except httpx.HTTPError as e:
|
|
127
129
|
raise ConnectionError(f"Failed to get client token: {str(e)}")
|
|
128
130
|
except Exception as e:
|
|
129
131
|
if isinstance(e, (AuthenticationError, ConnectionError)):
|
|
130
132
|
raise
|
|
131
133
|
raise AuthenticationError(f"Failed to get client token: {str(e)}")
|
|
132
|
-
|
|
134
|
+
|
|
133
135
|
async def _ensure_client_token(self):
|
|
134
136
|
"""Ensure client token is set in headers."""
|
|
135
137
|
token = await self._get_client_token()
|
|
136
138
|
if self.client:
|
|
137
139
|
self.client.headers["x-client-token"] = token
|
|
138
|
-
|
|
140
|
+
|
|
139
141
|
async def close(self):
|
|
140
142
|
"""Close the HTTP client."""
|
|
141
143
|
if self.client:
|
|
142
144
|
await self.client.aclose()
|
|
143
145
|
self.client = None
|
|
144
|
-
|
|
146
|
+
|
|
145
147
|
async def __aenter__(self):
|
|
146
148
|
"""Async context manager entry."""
|
|
147
149
|
return self
|
|
148
|
-
|
|
150
|
+
|
|
149
151
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
150
152
|
"""Async context manager exit."""
|
|
151
153
|
await self.close()
|
|
152
|
-
|
|
154
|
+
|
|
153
155
|
async def get(self, url: str, **kwargs) -> Any:
|
|
154
156
|
"""
|
|
155
157
|
Make GET request.
|
|
156
|
-
|
|
158
|
+
|
|
157
159
|
Args:
|
|
158
160
|
url: Request URL
|
|
159
161
|
**kwargs: Additional httpx request parameters
|
|
160
|
-
|
|
162
|
+
|
|
161
163
|
Returns:
|
|
162
164
|
Response data (JSON parsed)
|
|
163
|
-
|
|
165
|
+
|
|
164
166
|
Raises:
|
|
165
167
|
MisoClientError: If request fails
|
|
166
168
|
"""
|
|
@@ -169,35 +171,37 @@ class HttpClient:
|
|
|
169
171
|
try:
|
|
170
172
|
assert self.client is not None
|
|
171
173
|
response = await self.client.get(url, **kwargs)
|
|
172
|
-
|
|
174
|
+
|
|
173
175
|
# Handle 401 - clear token to force refresh
|
|
174
176
|
if response.status_code == 401:
|
|
175
177
|
self.client_token = None
|
|
176
178
|
self.token_expires_at = None
|
|
177
|
-
|
|
179
|
+
|
|
178
180
|
response.raise_for_status()
|
|
179
181
|
return response.json()
|
|
180
182
|
except httpx.HTTPStatusError as e:
|
|
181
183
|
raise MisoClientError(
|
|
182
184
|
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
183
185
|
status_code=e.response.status_code,
|
|
184
|
-
error_body=e.response.json()
|
|
186
|
+
error_body=e.response.json()
|
|
187
|
+
if e.response.headers.get("content-type", "").startswith("application/json")
|
|
188
|
+
else {},
|
|
185
189
|
)
|
|
186
190
|
except httpx.RequestError as e:
|
|
187
191
|
raise ConnectionError(f"Request failed: {str(e)}")
|
|
188
|
-
|
|
192
|
+
|
|
189
193
|
async def post(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
|
|
190
194
|
"""
|
|
191
195
|
Make POST request.
|
|
192
|
-
|
|
196
|
+
|
|
193
197
|
Args:
|
|
194
198
|
url: Request URL
|
|
195
199
|
data: Request data (will be JSON encoded)
|
|
196
200
|
**kwargs: Additional httpx request parameters
|
|
197
|
-
|
|
201
|
+
|
|
198
202
|
Returns:
|
|
199
203
|
Response data (JSON parsed)
|
|
200
|
-
|
|
204
|
+
|
|
201
205
|
Raises:
|
|
202
206
|
MisoClientError: If request fails
|
|
203
207
|
"""
|
|
@@ -206,34 +210,36 @@ class HttpClient:
|
|
|
206
210
|
try:
|
|
207
211
|
assert self.client is not None
|
|
208
212
|
response = await self.client.post(url, json=data, **kwargs)
|
|
209
|
-
|
|
213
|
+
|
|
210
214
|
if response.status_code == 401:
|
|
211
215
|
self.client_token = None
|
|
212
216
|
self.token_expires_at = None
|
|
213
|
-
|
|
217
|
+
|
|
214
218
|
response.raise_for_status()
|
|
215
219
|
return response.json()
|
|
216
220
|
except httpx.HTTPStatusError as e:
|
|
217
221
|
raise MisoClientError(
|
|
218
222
|
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
219
223
|
status_code=e.response.status_code,
|
|
220
|
-
error_body=e.response.json()
|
|
224
|
+
error_body=e.response.json()
|
|
225
|
+
if e.response.headers.get("content-type", "").startswith("application/json")
|
|
226
|
+
else {},
|
|
221
227
|
)
|
|
222
228
|
except httpx.RequestError as e:
|
|
223
229
|
raise ConnectionError(f"Request failed: {str(e)}")
|
|
224
|
-
|
|
230
|
+
|
|
225
231
|
async def put(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
|
|
226
232
|
"""
|
|
227
233
|
Make PUT request.
|
|
228
|
-
|
|
234
|
+
|
|
229
235
|
Args:
|
|
230
236
|
url: Request URL
|
|
231
237
|
data: Request data (will be JSON encoded)
|
|
232
238
|
**kwargs: Additional httpx request parameters
|
|
233
|
-
|
|
239
|
+
|
|
234
240
|
Returns:
|
|
235
241
|
Response data (JSON parsed)
|
|
236
|
-
|
|
242
|
+
|
|
237
243
|
Raises:
|
|
238
244
|
MisoClientError: If request fails
|
|
239
245
|
"""
|
|
@@ -242,33 +248,35 @@ class HttpClient:
|
|
|
242
248
|
try:
|
|
243
249
|
assert self.client is not None
|
|
244
250
|
response = await self.client.put(url, json=data, **kwargs)
|
|
245
|
-
|
|
251
|
+
|
|
246
252
|
if response.status_code == 401:
|
|
247
253
|
self.client_token = None
|
|
248
254
|
self.token_expires_at = None
|
|
249
|
-
|
|
255
|
+
|
|
250
256
|
response.raise_for_status()
|
|
251
257
|
return response.json()
|
|
252
258
|
except httpx.HTTPStatusError as e:
|
|
253
259
|
raise MisoClientError(
|
|
254
260
|
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
255
261
|
status_code=e.response.status_code,
|
|
256
|
-
error_body=e.response.json()
|
|
262
|
+
error_body=e.response.json()
|
|
263
|
+
if e.response.headers.get("content-type", "").startswith("application/json")
|
|
264
|
+
else {},
|
|
257
265
|
)
|
|
258
266
|
except httpx.RequestError as e:
|
|
259
267
|
raise ConnectionError(f"Request failed: {str(e)}")
|
|
260
|
-
|
|
268
|
+
|
|
261
269
|
async def delete(self, url: str, **kwargs) -> Any:
|
|
262
270
|
"""
|
|
263
271
|
Make DELETE request.
|
|
264
|
-
|
|
272
|
+
|
|
265
273
|
Args:
|
|
266
274
|
url: Request URL
|
|
267
275
|
**kwargs: Additional httpx request parameters
|
|
268
|
-
|
|
276
|
+
|
|
269
277
|
Returns:
|
|
270
278
|
Response data (JSON parsed)
|
|
271
|
-
|
|
279
|
+
|
|
272
280
|
Raises:
|
|
273
281
|
MisoClientError: If request fails
|
|
274
282
|
"""
|
|
@@ -277,41 +285,43 @@ class HttpClient:
|
|
|
277
285
|
try:
|
|
278
286
|
assert self.client is not None
|
|
279
287
|
response = await self.client.delete(url, **kwargs)
|
|
280
|
-
|
|
288
|
+
|
|
281
289
|
if response.status_code == 401:
|
|
282
290
|
self.client_token = None
|
|
283
291
|
self.token_expires_at = None
|
|
284
|
-
|
|
292
|
+
|
|
285
293
|
response.raise_for_status()
|
|
286
294
|
return response.json()
|
|
287
295
|
except httpx.HTTPStatusError as e:
|
|
288
296
|
raise MisoClientError(
|
|
289
297
|
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
290
298
|
status_code=e.response.status_code,
|
|
291
|
-
error_body=e.response.json()
|
|
299
|
+
error_body=e.response.json()
|
|
300
|
+
if e.response.headers.get("content-type", "").startswith("application/json")
|
|
301
|
+
else {},
|
|
292
302
|
)
|
|
293
303
|
except httpx.RequestError as e:
|
|
294
304
|
raise ConnectionError(f"Request failed: {str(e)}")
|
|
295
|
-
|
|
305
|
+
|
|
296
306
|
async def request(
|
|
297
307
|
self,
|
|
298
308
|
method: Literal["GET", "POST", "PUT", "DELETE"],
|
|
299
309
|
url: str,
|
|
300
310
|
data: Optional[Dict[str, Any]] = None,
|
|
301
|
-
**kwargs
|
|
311
|
+
**kwargs,
|
|
302
312
|
) -> Any:
|
|
303
313
|
"""
|
|
304
314
|
Generic request method.
|
|
305
|
-
|
|
315
|
+
|
|
306
316
|
Args:
|
|
307
317
|
method: HTTP method
|
|
308
318
|
url: Request URL
|
|
309
319
|
data: Request data (for POST/PUT)
|
|
310
320
|
**kwargs: Additional httpx request parameters
|
|
311
|
-
|
|
321
|
+
|
|
312
322
|
Returns:
|
|
313
323
|
Response data (JSON parsed)
|
|
314
|
-
|
|
324
|
+
|
|
315
325
|
Raises:
|
|
316
326
|
MisoClientError: If request fails
|
|
317
327
|
"""
|
|
@@ -326,51 +336,51 @@ class HttpClient:
|
|
|
326
336
|
return await self.delete(url, **kwargs)
|
|
327
337
|
else:
|
|
328
338
|
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
329
|
-
|
|
339
|
+
|
|
330
340
|
async def authenticated_request(
|
|
331
341
|
self,
|
|
332
342
|
method: Literal["GET", "POST", "PUT", "DELETE"],
|
|
333
343
|
url: str,
|
|
334
344
|
token: str,
|
|
335
345
|
data: Optional[Dict[str, Any]] = None,
|
|
336
|
-
**kwargs
|
|
346
|
+
**kwargs,
|
|
337
347
|
) -> Any:
|
|
338
348
|
"""
|
|
339
349
|
Make authenticated request with Bearer token.
|
|
340
|
-
|
|
350
|
+
|
|
341
351
|
IMPORTANT: Client token is sent as x-client-token header (via _ensure_client_token)
|
|
342
352
|
User token is sent as Authorization: Bearer header (this method parameter)
|
|
343
353
|
These are two separate tokens for different purposes.
|
|
344
|
-
|
|
354
|
+
|
|
345
355
|
Args:
|
|
346
356
|
method: HTTP method
|
|
347
357
|
url: Request URL
|
|
348
358
|
token: User authentication token (sent as Bearer token)
|
|
349
359
|
data: Request data (for POST/PUT)
|
|
350
360
|
**kwargs: Additional httpx request parameters
|
|
351
|
-
|
|
361
|
+
|
|
352
362
|
Returns:
|
|
353
363
|
Response data (JSON parsed)
|
|
354
|
-
|
|
364
|
+
|
|
355
365
|
Raises:
|
|
356
366
|
MisoClientError: If request fails
|
|
357
367
|
"""
|
|
358
368
|
await self._ensure_client_token()
|
|
359
|
-
|
|
369
|
+
|
|
360
370
|
# Add Bearer token for user authentication
|
|
361
371
|
# x-client-token is automatically added by _ensure_client_token
|
|
362
372
|
headers = kwargs.get("headers", {})
|
|
363
373
|
headers["Authorization"] = f"Bearer {token}"
|
|
364
374
|
kwargs["headers"] = headers
|
|
365
|
-
|
|
375
|
+
|
|
366
376
|
return await self.request(method, url, data, **kwargs)
|
|
367
|
-
|
|
377
|
+
|
|
368
378
|
async def get_environment_token(self) -> str:
|
|
369
379
|
"""
|
|
370
380
|
Get environment token using client credentials.
|
|
371
|
-
|
|
381
|
+
|
|
372
382
|
This is called automatically by HttpClient but can be called manually.
|
|
373
|
-
|
|
383
|
+
|
|
374
384
|
Returns:
|
|
375
385
|
Client token string
|
|
376
386
|
"""
|
miso_client/utils/jwt_tools.py
CHANGED
|
@@ -5,21 +5,22 @@ This module provides utilities for extracting information from JWT tokens
|
|
|
5
5
|
without verification, used for cache optimization and context extraction.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from typing import Any, Dict, Optional, cast
|
|
9
|
+
|
|
8
10
|
import jwt
|
|
9
|
-
from typing import Optional, Dict, Any, cast
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
|
13
14
|
"""
|
|
14
15
|
Safely decode JWT token without verification.
|
|
15
|
-
|
|
16
|
+
|
|
16
17
|
This is used for extracting user information (like userId) from tokens
|
|
17
18
|
for cache optimization. The token is NOT verified - it should only be
|
|
18
19
|
used for cache key generation, not for authentication decisions.
|
|
19
|
-
|
|
20
|
+
|
|
20
21
|
Args:
|
|
21
22
|
token: JWT token string
|
|
22
|
-
|
|
23
|
+
|
|
23
24
|
Returns:
|
|
24
25
|
Decoded token payload as dictionary, or None if decoding fails
|
|
25
26
|
"""
|
|
@@ -35,44 +36,40 @@ def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
|
|
35
36
|
def extract_user_id(token: str) -> Optional[str]:
|
|
36
37
|
"""
|
|
37
38
|
Extract user ID from JWT token.
|
|
38
|
-
|
|
39
|
+
|
|
39
40
|
Tries common JWT claim fields: sub, userId, user_id, id
|
|
40
|
-
|
|
41
|
+
|
|
41
42
|
Args:
|
|
42
43
|
token: JWT token string
|
|
43
|
-
|
|
44
|
+
|
|
44
45
|
Returns:
|
|
45
46
|
User ID string if found, None otherwise
|
|
46
47
|
"""
|
|
47
48
|
decoded = decode_token(token)
|
|
48
49
|
if not decoded:
|
|
49
50
|
return None
|
|
50
|
-
|
|
51
|
+
|
|
51
52
|
# Try common JWT claim fields for user ID
|
|
52
53
|
user_id = (
|
|
53
|
-
decoded.get("sub") or
|
|
54
|
-
decoded.get("userId") or
|
|
55
|
-
decoded.get("user_id") or
|
|
56
|
-
decoded.get("id")
|
|
54
|
+
decoded.get("sub") or decoded.get("userId") or decoded.get("user_id") or decoded.get("id")
|
|
57
55
|
)
|
|
58
|
-
|
|
56
|
+
|
|
59
57
|
return str(user_id) if user_id else None
|
|
60
58
|
|
|
61
59
|
|
|
62
60
|
def extract_session_id(token: str) -> Optional[str]:
|
|
63
61
|
"""
|
|
64
62
|
Extract session ID from JWT token.
|
|
65
|
-
|
|
63
|
+
|
|
66
64
|
Args:
|
|
67
65
|
token: JWT token string
|
|
68
|
-
|
|
66
|
+
|
|
69
67
|
Returns:
|
|
70
68
|
Session ID string if found, None otherwise
|
|
71
69
|
"""
|
|
72
70
|
decoded = decode_token(token)
|
|
73
71
|
if not decoded:
|
|
74
72
|
return None
|
|
75
|
-
|
|
73
|
+
|
|
76
74
|
value = decoded.get("sid") or decoded.get("sessionId")
|
|
77
75
|
return value if isinstance(value, str) else None
|
|
78
|
-
|