appmesh 1.4.7__py3-none-any.whl → 1.5.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.
- appmesh/http_client.py +293 -108
- appmesh/tcp_client.py +10 -10
- appmesh/tcp_transport.py +58 -31
- {appmesh-1.4.7.dist-info → appmesh-1.5.0.dist-info}/METADATA +2 -2
- appmesh-1.5.0.dist-info/RECORD +13 -0
- {appmesh-1.4.7.dist-info → appmesh-1.5.0.dist-info}/WHEEL +1 -1
- appmesh/keycloak.py +0 -232
- appmesh-1.4.7.dist-info/RECORD +0 -14
- {appmesh-1.4.7.dist-info → appmesh-1.5.0.dist-info}/top_level.txt +0 -0
appmesh/http_client.py
CHANGED
@@ -3,18 +3,21 @@
|
|
3
3
|
import abc
|
4
4
|
import base64
|
5
5
|
import json
|
6
|
+
import logging
|
6
7
|
import os
|
7
8
|
from datetime import datetime
|
8
9
|
from enum import Enum, unique
|
9
10
|
from http import HTTPStatus
|
11
|
+
import threading
|
10
12
|
from typing import Optional, Tuple, Union
|
11
13
|
from urllib import parse
|
12
14
|
import aniso8601
|
15
|
+
import jwt
|
13
16
|
import requests
|
17
|
+
import time
|
14
18
|
from .app import App
|
15
19
|
from .app_run import AppRun
|
16
20
|
from .app_output import AppOutput
|
17
|
-
from .keycloak import KeycloakClient
|
18
21
|
|
19
22
|
|
20
23
|
class AppMeshClient(metaclass=abc.ABCMeta):
|
@@ -103,6 +106,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
103
106
|
DURATION_ONE_WEEK_ISO = "P1W"
|
104
107
|
DURATION_TWO_DAYS_ISO = "P2D"
|
105
108
|
DURATION_TWO_DAYS_HALF_ISO = "P2DT12H"
|
109
|
+
TOKEN_REFRESH_INTERVAL = 60
|
106
110
|
|
107
111
|
DEFAULT_SSL_CA_CERT_PATH = "/opt/appmesh/ssl/ca.pem"
|
108
112
|
DEFAULT_SSL_CLIENT_CERT_PATH = "/opt/appmesh/ssl/client.pem"
|
@@ -114,6 +118,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
114
118
|
HTTP_USER_AGENT = "appmesh/python"
|
115
119
|
HTTP_HEADER_KEY_USER_AGENT = "User-Agent"
|
116
120
|
HTTP_HEADER_KEY_X_TARGET_HOST = "X-Target-Host"
|
121
|
+
HTTP_HEADER_KEY_X_FILE_PATH = "X-File-Path"
|
117
122
|
|
118
123
|
@unique
|
119
124
|
class Method(Enum):
|
@@ -132,7 +137,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
132
137
|
rest_ssl_client_cert=(DEFAULT_SSL_CLIENT_CERT_PATH, DEFAULT_SSL_CLIENT_KEY_PATH) if os.path.exists(DEFAULT_SSL_CLIENT_CERT_PATH) else None,
|
133
138
|
rest_timeout=(60, 300),
|
134
139
|
jwt_token=None,
|
135
|
-
|
140
|
+
oauth2=None,
|
141
|
+
auto_refresh_token=False,
|
136
142
|
):
|
137
143
|
"""Initialize an App Mesh HTTP client for interacting with the App Mesh server via secure HTTPS.
|
138
144
|
|
@@ -145,9 +151,9 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
145
151
|
- `str`: Path to a custom CA certificate or directory for verification. This option allows custom CA configuration,
|
146
152
|
which may be necessary in environments requiring specific CA chains that differ from the default system CAs.
|
147
153
|
|
148
|
-
|
149
|
-
-
|
150
|
-
-
|
154
|
+
ssl_client_cert (Union[str, Tuple[str, str]], optional): Path to the SSL client certificate and key. Can be:
|
155
|
+
- `str`: A path to a single PEM file containing both the client certificate and private key.
|
156
|
+
- `tuple`: A pair of paths (`cert_file`, `key_file`), where `cert_file` is the client certificate file path and `key_file` is the private key file path.
|
151
157
|
|
152
158
|
rest_timeout (tuple, optional): HTTP connection timeouts for API requests, as `(connect_timeout, read_timeout)`.
|
153
159
|
The default is `(60, 300)`, where `60` seconds is the maximum time to establish a connection and `300` seconds for the maximum read duration.
|
@@ -155,14 +161,20 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
155
161
|
jwt_token (str, optional): JWT token for API authentication, used in headers to authorize requests where required.
|
156
162
|
|
157
163
|
oauth2 (Dict[str, Any], optional): Keycloak configuration for oauth2 authentication:
|
158
|
-
- server_url: Keycloak server URL (e.g. "https://keycloak.example.com")
|
164
|
+
- server_url: Keycloak server URL (e.g. "https://keycloak.example.com/auth/")
|
159
165
|
- realm: Keycloak realm
|
160
166
|
- client_id: Keycloak client ID
|
161
167
|
- client_secret: Keycloak client secret (optional)
|
162
|
-
|
168
|
+
Using this parameter enables Keycloak integration for authentication. The 'python-keycloak' package
|
169
|
+
will be imported on-demand only when this parameter is, make sure package is installed (pip3 install python-keycloak).
|
163
170
|
|
171
|
+
auto_refresh_token (bool, optional): Enable automatic token refresh before expiration.
|
172
|
+
When enabled, a background timer will monitor token expiration and attempt to refresh
|
173
|
+
the token before it expires. This works with both native App Mesh tokens and Keycloak tokens.
|
174
|
+
"""
|
175
|
+
self._ensure_logging_configured()
|
164
176
|
self.session = requests.Session()
|
165
|
-
self.
|
177
|
+
self.auth_server_url = rest_url
|
166
178
|
self._jwt_token = jwt_token
|
167
179
|
self.ssl_verify = rest_ssl_verify
|
168
180
|
self.ssl_client_cert = rest_ssl_client_cert
|
@@ -170,32 +182,147 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
170
182
|
self._forward_to = None
|
171
183
|
|
172
184
|
# Keycloak integration
|
173
|
-
self.
|
174
|
-
if
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
185
|
+
self._keycloak_openid = None
|
186
|
+
if oauth2:
|
187
|
+
try:
|
188
|
+
from keycloak import KeycloakOpenID
|
189
|
+
|
190
|
+
self._keycloak_openid = KeycloakOpenID(
|
191
|
+
server_url=oauth2.get("auth_server_url"),
|
192
|
+
client_id=oauth2.get("client_id"),
|
193
|
+
realm_name=oauth2.get("realm"),
|
194
|
+
client_secret_key=oauth2.get("client_secret"),
|
195
|
+
verify=self.ssl_verify,
|
196
|
+
timeout=rest_timeout,
|
197
|
+
)
|
198
|
+
except ImportError:
|
199
|
+
logging.error("Keycloak package not installed. Install with: pip install python-keycloak")
|
200
|
+
raise Exception("Keycloak integration requested but python-keycloak package is not installed")
|
201
|
+
|
202
|
+
# Token auto-refresh
|
203
|
+
self._token_refresh_timer = None
|
204
|
+
self._auto_refresh_token = auto_refresh_token
|
205
|
+
if auto_refresh_token and jwt_token:
|
206
|
+
self._schedule_token_refresh()
|
207
|
+
|
208
|
+
@staticmethod
|
209
|
+
def _ensure_logging_configured():
|
210
|
+
"""Ensure logging is configured. If no handlers are configured, add a default console handler."""
|
211
|
+
if not logging.root.handlers:
|
212
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
213
|
+
|
214
|
+
def _check_and_refresh_token(self):
|
215
|
+
"""Check and refresh token if needed, then schedule next check.
|
216
|
+
|
217
|
+
This method is triggered by the refresh timer and will:
|
218
|
+
1. Check if token needs refresh based on expiration time
|
219
|
+
2. Refresh the token if needed
|
220
|
+
3. Schedule the next refresh check
|
221
|
+
"""
|
222
|
+
if not self.jwt_token:
|
223
|
+
return
|
224
|
+
|
225
|
+
# Check if token needs refresh
|
226
|
+
needs_refresh = True
|
227
|
+
time_to_expiry = float("inf")
|
228
|
+
|
229
|
+
# Check token expiration directly from JWT
|
230
|
+
try:
|
231
|
+
decoded_token = jwt.decode(self.jwt_token if isinstance(self.jwt_token, str) else self.jwt_token.get("access_token", ""), options={"verify_signature": False})
|
232
|
+
expiry = decoded_token.get("exp", 0)
|
233
|
+
current_time = time.time()
|
234
|
+
time_to_expiry = expiry - current_time
|
235
|
+
# Refresh if token expires within 5 minutes
|
236
|
+
needs_refresh = time_to_expiry < 300
|
237
|
+
except Exception as e:
|
238
|
+
logging.debug("Failed to parse JWT token for expiration check: %s", str(e))
|
239
|
+
|
240
|
+
# Refresh token if needed
|
241
|
+
if needs_refresh:
|
242
|
+
try:
|
243
|
+
self.renew_token()
|
244
|
+
logging.info("Token successfully refreshed")
|
245
|
+
except Exception as e:
|
246
|
+
logging.error("Token refresh failed: %s", str(e))
|
247
|
+
|
248
|
+
# Schedule next check if auto-refresh is still enabled
|
249
|
+
if self._auto_refresh_token and self.jwt_token:
|
250
|
+
self._schedule_token_refresh(time_to_expiry)
|
251
|
+
|
252
|
+
def _schedule_token_refresh(self, time_to_expiry=None):
|
253
|
+
"""Schedule next token refresh check.
|
254
|
+
|
255
|
+
Args:
|
256
|
+
time_to_expiry (float, optional): Time in seconds until token expiration.
|
257
|
+
When provided, helps calculate optimal refresh timing.
|
258
|
+
|
259
|
+
Calculates appropriate check interval:
|
260
|
+
- If token expires soon (within 5 minutes), refresh immediately
|
261
|
+
- Otherwise schedule refresh for the earlier of:
|
262
|
+
1. 5 minutes before expiration
|
263
|
+
2. 60 seconds from now
|
264
|
+
"""
|
265
|
+
# Cancel existing timer if any
|
266
|
+
if self._token_refresh_timer:
|
267
|
+
self._token_refresh_timer.cancel()
|
268
|
+
self._token_refresh_timer = None
|
269
|
+
|
270
|
+
try:
|
271
|
+
# Default to checking after 60 seconds
|
272
|
+
check_interval = self.TOKEN_REFRESH_INTERVAL
|
273
|
+
|
274
|
+
# Calculate more precise check time if expiry is known
|
275
|
+
if time_to_expiry is not None:
|
276
|
+
if time_to_expiry <= 300: # Expires within 5 minutes
|
277
|
+
check_interval = 1 # Almost immediate refresh
|
278
|
+
else:
|
279
|
+
# Check at earlier of 5 minutes before expiry or regular interval
|
280
|
+
check_interval = min(time_to_expiry - 300, self.TOKEN_REFRESH_INTERVAL)
|
281
|
+
|
282
|
+
# Create timer to execute refresh check
|
283
|
+
self._token_refresh_timer = threading.Timer(check_interval, self._check_and_refresh_token)
|
284
|
+
self._token_refresh_timer.daemon = True
|
285
|
+
self._token_refresh_timer.start()
|
286
|
+
logging.debug("Auto-refresh: Next token check scheduled in %.1f seconds", check_interval)
|
287
|
+
except Exception as e:
|
288
|
+
logging.error("Auto-refresh: Failed to schedule token refresh: %s", str(e))
|
289
|
+
|
184
290
|
def close(self):
|
185
291
|
"""Close the session and release resources."""
|
186
|
-
|
292
|
+
# Cancel token refresh timer
|
293
|
+
if hasattr(self, "_token_refresh_timer") and self._token_refresh_timer:
|
294
|
+
self._token_refresh_timer.cancel()
|
295
|
+
self._token_refresh_timer = None
|
296
|
+
|
297
|
+
# Close the session
|
298
|
+
if hasattr(self, "session") and self.session:
|
187
299
|
self.session.close()
|
188
|
-
|
189
|
-
|
190
|
-
|
300
|
+
self.session = None
|
301
|
+
|
302
|
+
# Logout from Keycloak if needed
|
303
|
+
if hasattr(self, "_keycloak_openid") and self._keycloak_openid and hasattr(self, "_jwt_token") and self._jwt_token and isinstance(self._jwt_token, dict) and "refresh_token" in self._jwt_token:
|
304
|
+
try:
|
305
|
+
self._keycloak_openid.logout(self._jwt_token.get("refresh_token"))
|
306
|
+
except Exception as e:
|
307
|
+
logging.warning("Failed to logout from Keycloak: %s", str(e))
|
308
|
+
finally:
|
309
|
+
self._keycloak_openid = None
|
310
|
+
|
191
311
|
def __enter__(self):
|
192
312
|
"""Support for context manager protocol."""
|
193
313
|
return self
|
194
|
-
|
314
|
+
|
195
315
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
196
316
|
"""Support for context manager protocol, ensuring resources are released."""
|
197
317
|
self.close()
|
198
318
|
|
319
|
+
def __del__(self):
|
320
|
+
"""Ensure resources are properly released when the object is garbage collected."""
|
321
|
+
try:
|
322
|
+
self.close()
|
323
|
+
except Exception:
|
324
|
+
pass # Avoid exceptions during garbage collection
|
325
|
+
|
199
326
|
@property
|
200
327
|
def jwt_token(self) -> str:
|
201
328
|
"""Get the current JWT (JSON Web Token) used for authentication.
|
@@ -302,8 +429,17 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
302
429
|
str: JWT token.
|
303
430
|
"""
|
304
431
|
# Keycloak authentication if configured
|
305
|
-
if self.
|
306
|
-
self.jwt_token = self.
|
432
|
+
if self._keycloak_openid:
|
433
|
+
self.jwt_token = self._keycloak_openid.token(
|
434
|
+
username=user_name,
|
435
|
+
password=user_pwd,
|
436
|
+
totp=totp_code if totp_code else None,
|
437
|
+
grant_type="password", # grant type for token request: "password" / "client_credentials" / "refresh_token"
|
438
|
+
scope="openid", # what information to include in the token, such as "openid profile email"
|
439
|
+
)
|
440
|
+
|
441
|
+
if self._auto_refresh_token:
|
442
|
+
self._schedule_token_refresh()
|
307
443
|
return self.jwt_token
|
308
444
|
|
309
445
|
# Standard App Mesh authentication
|
@@ -313,18 +449,22 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
313
449
|
path="/appmesh/login",
|
314
450
|
header={
|
315
451
|
"Authorization": "Basic " + base64.b64encode((user_name + ":" + user_pwd).encode()).decode(),
|
316
|
-
"Expire-Seconds": self._parse_duration(timeout_seconds),
|
317
|
-
**({"Audience": audience} if audience else {}),
|
452
|
+
"X-Expire-Seconds": self._parse_duration(timeout_seconds),
|
453
|
+
**({"X-Audience": audience} if audience else {}),
|
454
|
+
# **({"X-Totp-Code": totp_code} if totp_code else {}),
|
318
455
|
},
|
319
456
|
)
|
320
457
|
if resp.status_code == HTTPStatus.OK:
|
321
|
-
if "
|
322
|
-
self.jwt_token = resp.json()["
|
323
|
-
elif resp.status_code == HTTPStatus.PRECONDITION_REQUIRED and "
|
324
|
-
challenge = resp.json()["
|
458
|
+
if "access_token" in resp.json():
|
459
|
+
self.jwt_token = resp.json()["access_token"]
|
460
|
+
elif resp.status_code == HTTPStatus.PRECONDITION_REQUIRED and "totp_challenge" in resp.json():
|
461
|
+
challenge = resp.json()["totp_challenge"]
|
325
462
|
self.validate_totp(user_name, challenge, totp_code, timeout_seconds)
|
326
463
|
else:
|
327
464
|
raise Exception(resp.text)
|
465
|
+
|
466
|
+
if self._auto_refresh_token:
|
467
|
+
self._schedule_token_refresh()
|
328
468
|
return self.jwt_token
|
329
469
|
|
330
470
|
def validate_totp(self, username: str, challenge: str, code: str, timeout: Union[int, str] = DURATION_ONE_WEEK_ISO) -> str:
|
@@ -347,17 +487,16 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
347
487
|
resp = self._request_http(
|
348
488
|
AppMeshClient.Method.POST,
|
349
489
|
path="/appmesh/totp/validate",
|
350
|
-
|
351
|
-
"
|
352
|
-
"
|
353
|
-
"
|
354
|
-
"
|
490
|
+
body={
|
491
|
+
"user_name": username,
|
492
|
+
"totp_code": code,
|
493
|
+
"totp_challenge": challenge,
|
494
|
+
"expire_seconds": self._parse_duration(timeout),
|
355
495
|
},
|
356
496
|
)
|
357
|
-
if resp.status_code == HTTPStatus.OK:
|
358
|
-
|
359
|
-
|
360
|
-
return self.jwt_token
|
497
|
+
if resp.status_code == HTTPStatus.OK and "access_token" in resp.json():
|
498
|
+
self.jwt_token = resp.json()["access_token"]
|
499
|
+
return self.jwt_token
|
361
500
|
raise Exception(resp.text)
|
362
501
|
|
363
502
|
def logoff(self) -> bool:
|
@@ -366,15 +505,30 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
366
505
|
Returns:
|
367
506
|
bool: logoff success or failure.
|
368
507
|
"""
|
369
|
-
|
508
|
+
result = False
|
509
|
+
# Handle Keycloak logout if configured
|
510
|
+
if self._keycloak_openid and self.jwt_token and isinstance(self.jwt_token, dict) and "refresh_token" in self.jwt_token:
|
511
|
+
refresh_token = self.jwt_token.get("refresh_token")
|
512
|
+
self._keycloak_openid.logout(refresh_token)
|
513
|
+
self.jwt_token = None
|
514
|
+
result = True
|
515
|
+
|
516
|
+
# Standard App Mesh logout
|
517
|
+
if self.jwt_token and isinstance(self.jwt_token, str):
|
370
518
|
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/self/logoff")
|
371
519
|
self.jwt_token = None
|
372
|
-
|
373
|
-
|
520
|
+
result = resp.status_code == HTTPStatus.OK
|
521
|
+
|
522
|
+
# Cancel token refresh timer
|
523
|
+
if self._token_refresh_timer:
|
524
|
+
self._token_refresh_timer.cancel()
|
525
|
+
self._token_refresh_timer = None
|
526
|
+
|
527
|
+
return result
|
374
528
|
|
375
529
|
def authentication(self, token: str, permission: Optional[str] = None, audience: Optional[str] = None) -> bool:
|
376
530
|
"""Deprecated: Use authenticate() instead."""
|
377
|
-
return self.authenticate(token, permission)
|
531
|
+
return self.authenticate(token, permission, audience)
|
378
532
|
|
379
533
|
def authenticate(self, token: str, permission: Optional[str] = None, audience: Optional[str] = None) -> bool:
|
380
534
|
"""Authenticate with a token and verify permission if specified.
|
@@ -393,8 +547,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
393
547
|
old_token = self.jwt_token
|
394
548
|
self.jwt_token = token
|
395
549
|
headers = {
|
396
|
-
**({"Audience": audience} if audience else {}),
|
397
|
-
**({"
|
550
|
+
**({"X-Audience": audience} if audience else {}),
|
551
|
+
**({"X-Permission": permission} if permission else {}),
|
398
552
|
}
|
399
553
|
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/auth", header=headers)
|
400
554
|
if resp.status_code != HTTPStatus.OK:
|
@@ -409,27 +563,46 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
409
563
|
timeout_seconds (int | str, optional): token expire timeout of seconds. support ISO 8601 durations (e.g., 'P1Y2M3DT4H5M6S' 'P1W').
|
410
564
|
|
411
565
|
Returns:
|
412
|
-
str: The new JWT token
|
566
|
+
str: The new JWT token. The old token will be invalidated.
|
567
|
+
|
568
|
+
Raises:
|
569
|
+
Exception: If token renewal fails or no token exists to renew
|
413
570
|
"""
|
414
|
-
#
|
415
|
-
if self.
|
416
|
-
|
571
|
+
# Ensure token exists
|
572
|
+
if not self.jwt_token:
|
573
|
+
raise Exception("No token to renew")
|
574
|
+
|
575
|
+
try:
|
576
|
+
# Handle Keycloak token (dictionary format)
|
577
|
+
if self._keycloak_openid and isinstance(self.jwt_token, dict) and "refresh_token" in self.jwt_token:
|
578
|
+
new_token = self._keycloak_openid.refresh_token(self.jwt_token.get("refresh_token"))
|
579
|
+
self.jwt_token = new_token
|
580
|
+
|
581
|
+
# Handle App Mesh token (string format)
|
582
|
+
elif isinstance(self.jwt_token, str):
|
583
|
+
resp = self._request_http(
|
584
|
+
AppMeshClient.Method.POST,
|
585
|
+
path="/appmesh/token/renew",
|
586
|
+
header={
|
587
|
+
"X-Expire-Seconds": self._parse_duration(timeout),
|
588
|
+
},
|
589
|
+
)
|
590
|
+
if resp.status_code == HTTPStatus.OK:
|
591
|
+
if "access_token" in resp.json():
|
592
|
+
new_token = resp.json()["access_token"]
|
593
|
+
self.jwt_token = new_token
|
594
|
+
else:
|
595
|
+
raise Exception("Token renewal response missing access_token")
|
596
|
+
else:
|
597
|
+
raise Exception(resp.text)
|
598
|
+
else:
|
599
|
+
raise Exception("Unsupported token format")
|
600
|
+
|
417
601
|
return self.jwt_token
|
418
602
|
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
AppMeshClient.Method.POST,
|
423
|
-
path="/appmesh/token/renew",
|
424
|
-
header={
|
425
|
-
"Expire-Seconds": self._parse_duration(timeout),
|
426
|
-
},
|
427
|
-
)
|
428
|
-
if resp.status_code == HTTPStatus.OK:
|
429
|
-
if "Access-Token" in resp.json():
|
430
|
-
self.jwt_token = resp.json()["Access-Token"]
|
431
|
-
return self.jwt_token
|
432
|
-
raise Exception(resp.text)
|
603
|
+
except Exception as e:
|
604
|
+
logging.error("Token renewal failed: %s", str(e))
|
605
|
+
raise Exception(f"Token renewal failed: {str(e)}") from e
|
433
606
|
|
434
607
|
def get_totp_secret(self) -> str:
|
435
608
|
"""Generate TOTP secret for the current user and return MFA URI.
|
@@ -439,7 +612,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
439
612
|
"""
|
440
613
|
resp = self._request_http(method=AppMeshClient.Method.POST, path="/appmesh/totp/secret")
|
441
614
|
if resp.status_code == HTTPStatus.OK:
|
442
|
-
totp_uri = base64.b64decode(resp.json()["
|
615
|
+
totp_uri = base64.b64decode(resp.json()["mfa_uri"]).decode()
|
443
616
|
return self._parse_totp_uri(totp_uri).get("secret")
|
444
617
|
raise Exception(resp.text)
|
445
618
|
|
@@ -455,11 +628,11 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
455
628
|
resp = self._request_http(
|
456
629
|
method=AppMeshClient.Method.POST,
|
457
630
|
path="/appmesh/totp/setup",
|
458
|
-
header={"Totp": totp_code},
|
631
|
+
header={"X-Totp-Code": totp_code},
|
459
632
|
)
|
460
633
|
if resp.status_code == HTTPStatus.OK:
|
461
|
-
if "
|
462
|
-
self.jwt_token = resp.json()["
|
634
|
+
if "access_token" in resp.json():
|
635
|
+
self.jwt_token = resp.json()["access_token"]
|
463
636
|
return self.jwt_token
|
464
637
|
else:
|
465
638
|
raise Exception(resp.text)
|
@@ -566,8 +739,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
566
739
|
"timeout": str(timeout),
|
567
740
|
},
|
568
741
|
)
|
569
|
-
out_position = int(resp.headers["Output-Position"]) if "Output-Position" in resp.headers else None
|
570
|
-
exit_code = int(resp.headers["Exit-Code"]) if "Exit-Code" in resp.headers else None
|
742
|
+
out_position = int(resp.headers["X-Output-Position"]) if "X-Output-Position" in resp.headers else None
|
743
|
+
exit_code = int(resp.headers["X-Exit-Code"]) if "X-Exit-Code" in resp.headers else None
|
571
744
|
return AppOutput(status_code=resp.status_code, output=resp.text, out_position=out_position, exit_code=exit_code)
|
572
745
|
|
573
746
|
def check_app_health(self, app_name: str) -> bool:
|
@@ -718,7 +891,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
718
891
|
resp = self._request_http(
|
719
892
|
method=AppMeshClient.Method.POST,
|
720
893
|
path=f"/appmesh/user/{user_name}/passwd",
|
721
|
-
|
894
|
+
body={"new_password": base64.b64encode(new_password.encode()).decode()},
|
722
895
|
)
|
723
896
|
if resp.status_code != HTTPStatus.OK:
|
724
897
|
raise Exception(resp.text)
|
@@ -807,6 +980,9 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
807
980
|
Returns:
|
808
981
|
dict: user definition.
|
809
982
|
"""
|
983
|
+
if self._keycloak_openid and isinstance(self.jwt_token, dict) and "access_token" in self.jwt_token:
|
984
|
+
return self._keycloak_openid.userinfo(self.jwt_token.get("access_token"))
|
985
|
+
|
810
986
|
resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/user/self")
|
811
987
|
if resp.status_code != HTTPStatus.OK:
|
812
988
|
raise Exception(resp.text)
|
@@ -960,7 +1136,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
960
1136
|
local_file (str): the local file path to be downloaded.
|
961
1137
|
apply_file_attributes (bool): whether to apply file attributes (permissions, owner, group) to the local file.
|
962
1138
|
"""
|
963
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header={
|
1139
|
+
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header={self.HTTP_HEADER_KEY_X_FILE_PATH: remote_file})
|
964
1140
|
resp.raise_for_status()
|
965
1141
|
|
966
1142
|
# Write the file content locally
|
@@ -971,15 +1147,15 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
971
1147
|
|
972
1148
|
# Apply file attributes (permissions, owner, group) if requested
|
973
1149
|
if apply_file_attributes:
|
974
|
-
if "File-Mode" in resp.headers:
|
975
|
-
os.chmod(path=local_file, mode=int(resp.headers["File-Mode"]))
|
976
|
-
if "File-User" in resp.headers and "File-Group" in resp.headers:
|
977
|
-
file_uid = int(resp.headers["File-User"])
|
978
|
-
file_gid = int(resp.headers["File-Group"])
|
1150
|
+
if "X-File-Mode" in resp.headers:
|
1151
|
+
os.chmod(path=local_file, mode=int(resp.headers["X-File-Mode"]))
|
1152
|
+
if "X-File-User" in resp.headers and "X-File-Group" in resp.headers:
|
1153
|
+
file_uid = int(resp.headers["X-File-User"])
|
1154
|
+
file_gid = int(resp.headers["X-File-Group"])
|
979
1155
|
try:
|
980
1156
|
os.chown(path=local_file, uid=file_uid, gid=file_gid)
|
981
1157
|
except PermissionError:
|
982
|
-
|
1158
|
+
logging.warning(f"Warning: Unable to change owner/group of {local_file}. Operation requires elevated privileges.")
|
983
1159
|
|
984
1160
|
def upload_file(self, local_file: str, remote_file: str, apply_file_attributes: bool = True) -> None:
|
985
1161
|
"""Upload a local file to the remote server. Optionally, the remote file will have the same permission as the local file.
|
@@ -1000,14 +1176,14 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1000
1176
|
|
1001
1177
|
with open(file=local_file, mode="rb") as fp:
|
1002
1178
|
encoder = MultipartEncoder(fields={"filename": os.path.basename(remote_file), "file": ("filename", fp, "application/octet-stream")})
|
1003
|
-
header = {
|
1179
|
+
header = {self.HTTP_HEADER_KEY_X_FILE_PATH: parse.quote(remote_file), "Content-Type": encoder.content_type}
|
1004
1180
|
|
1005
1181
|
# Include file attributes (permissions, owner, group) if requested
|
1006
1182
|
if apply_file_attributes:
|
1007
1183
|
file_stat = os.stat(local_file)
|
1008
|
-
header["File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
|
1009
|
-
header["File-User"] = str(file_stat.st_uid)
|
1010
|
-
header["File-Group"] = str(file_stat.st_gid)
|
1184
|
+
header["X-File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
|
1185
|
+
header["X-File-User"] = str(file_stat.st_uid)
|
1186
|
+
header["X-File-Group"] = str(file_stat.st_gid)
|
1011
1187
|
|
1012
1188
|
# Upload file with or without attributes
|
1013
1189
|
# https://stackoverflow.com/questions/22567306/python-requests-file-upload
|
@@ -1090,12 +1266,15 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1090
1266
|
while len(run.proc_uid) > 0:
|
1091
1267
|
app_out = self.get_app_output(app_name=run.app_name, stdout_position=last_output_position, stdout_index=0, process_uuid=run.proc_uid, timeout=interval)
|
1092
1268
|
if app_out.output and stdout_print:
|
1093
|
-
print(app_out.output, end="")
|
1269
|
+
print(app_out.output, end="", flush=True)
|
1094
1270
|
if app_out.out_position is not None:
|
1095
1271
|
last_output_position = app_out.out_position
|
1096
1272
|
if app_out.exit_code is not None:
|
1097
1273
|
# success
|
1098
|
-
|
1274
|
+
try:
|
1275
|
+
self.delete_app(run.app_name)
|
1276
|
+
except Exception:
|
1277
|
+
pass
|
1099
1278
|
return app_out.exit_code
|
1100
1279
|
if app_out.status_code != HTTPStatus.OK:
|
1101
1280
|
# failed
|
@@ -1147,8 +1326,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1147
1326
|
if resp.status_code == HTTPStatus.OK:
|
1148
1327
|
if stdout_print:
|
1149
1328
|
print(resp.text, end="")
|
1150
|
-
if "Exit-Code" in resp.headers:
|
1151
|
-
exit_code = int(resp.headers.get("Exit-Code"))
|
1329
|
+
if "X-Exit-Code" in resp.headers:
|
1330
|
+
exit_code = int(resp.headers.get("X-Exit-Code"))
|
1152
1331
|
elif stdout_print:
|
1153
1332
|
print(resp.text)
|
1154
1333
|
|
@@ -1167,33 +1346,39 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1167
1346
|
Returns:
|
1168
1347
|
requests.Response: HTTP response
|
1169
1348
|
"""
|
1170
|
-
|
1171
|
-
if self._keycloak_client and self.jwt_token and not self._keycloak_client.validate_token():
|
1172
|
-
self.jwt_token = self._keycloak_client.get_active_token()
|
1173
|
-
|
1174
|
-
rest_url = parse.urljoin(self.server_url, path)
|
1349
|
+
rest_url = parse.urljoin(self.auth_server_url, path)
|
1175
1350
|
|
1176
1351
|
header = {} if header is None else header
|
1177
1352
|
if self.jwt_token:
|
1178
|
-
|
1353
|
+
token = self.jwt_token["access_token"] if isinstance(self.jwt_token, dict) and "access_token" in self.jwt_token else self.jwt_token
|
1354
|
+
header["Authorization"] = "Bearer " + token
|
1179
1355
|
if self.forward_to and len(self.forward_to) > 0:
|
1180
1356
|
if ":" in self.forward_to:
|
1181
1357
|
header[self.HTTP_HEADER_KEY_X_TARGET_HOST] = self.forward_to
|
1182
1358
|
else:
|
1183
|
-
header[self.HTTP_HEADER_KEY_X_TARGET_HOST] = self.forward_to + ":" + str(parse.urlsplit(self.
|
1359
|
+
header[self.HTTP_HEADER_KEY_X_TARGET_HOST] = self.forward_to + ":" + str(parse.urlsplit(self.auth_server_url).port)
|
1184
1360
|
header[self.HTTP_HEADER_KEY_USER_AGENT] = self.HTTP_USER_AGENT
|
1185
1361
|
|
1186
|
-
|
1187
|
-
|
1188
|
-
|
1189
|
-
|
1190
|
-
|
1191
|
-
|
1192
|
-
|
1193
|
-
|
1194
|
-
|
1195
|
-
|
1196
|
-
|
1197
|
-
|
1198
|
-
|
1199
|
-
|
1362
|
+
try:
|
1363
|
+
if method is AppMeshClient.Method.GET:
|
1364
|
+
return self.session.get(url=rest_url, params=query, headers=header, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
|
1365
|
+
elif method is AppMeshClient.Method.POST:
|
1366
|
+
return self.session.post(
|
1367
|
+
url=rest_url,
|
1368
|
+
params=query,
|
1369
|
+
headers=header,
|
1370
|
+
data=json.dumps(body) if type(body) in (dict, list) else body,
|
1371
|
+
cert=self.ssl_client_cert,
|
1372
|
+
verify=self.ssl_verify,
|
1373
|
+
timeout=self.rest_timeout,
|
1374
|
+
)
|
1375
|
+
elif method is AppMeshClient.Method.POST_STREAM:
|
1376
|
+
return self.session.post(url=rest_url, params=query, headers=header, data=body, cert=self.ssl_client_cert, verify=self.ssl_verify, stream=True, timeout=self.rest_timeout)
|
1377
|
+
elif method is AppMeshClient.Method.DELETE:
|
1378
|
+
return self.session.delete(url=rest_url, headers=header, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
|
1379
|
+
elif method is AppMeshClient.Method.PUT:
|
1380
|
+
return self.session.put(url=rest_url, params=query, headers=header, json=body, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
|
1381
|
+
else:
|
1382
|
+
raise Exception("Invalid http method", method)
|
1383
|
+
except requests.exceptions.RequestException as e:
|
1384
|
+
raise Exception(f"HTTP request failed: {str(e)}")
|
appmesh/tcp_client.py
CHANGED
@@ -153,7 +153,7 @@ class AppMeshClientTCP(AppMeshClient):
|
|
153
153
|
local_file (str): the local file path to be downloaded.
|
154
154
|
apply_file_attributes (bool): whether to apply file attributes (permissions, owner, group) to the local file.
|
155
155
|
"""
|
156
|
-
header = {
|
156
|
+
header = {AppMeshClient.HTTP_HEADER_KEY_X_FILE_PATH: remote_file}
|
157
157
|
header[self.HTTP_HEADER_KEY_X_RECV_FILE_SOCKET] = "true"
|
158
158
|
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header=header)
|
159
159
|
|
@@ -169,11 +169,11 @@ class AppMeshClientTCP(AppMeshClient):
|
|
169
169
|
fp.write(chunk_data)
|
170
170
|
|
171
171
|
if apply_file_attributes:
|
172
|
-
if "File-Mode" in resp.headers:
|
173
|
-
os.chmod(path=local_file, mode=int(resp.headers["File-Mode"]))
|
174
|
-
if "File-User" in resp.headers and "File-Group" in resp.headers:
|
175
|
-
file_uid = int(resp.headers["File-User"])
|
176
|
-
file_gid = int(resp.headers["File-Group"])
|
172
|
+
if "X-File-Mode" in resp.headers:
|
173
|
+
os.chmod(path=local_file, mode=int(resp.headers["X-File-Mode"]))
|
174
|
+
if "X-File-User" in resp.headers and "X-File-Group" in resp.headers:
|
175
|
+
file_uid = int(resp.headers["X-File-User"])
|
176
|
+
file_gid = int(resp.headers["X-File-Group"])
|
177
177
|
try:
|
178
178
|
os.chown(path=local_file, uid=file_uid, gid=file_gid)
|
179
179
|
except PermissionError:
|
@@ -195,14 +195,14 @@ class AppMeshClientTCP(AppMeshClient):
|
|
195
195
|
raise FileNotFoundError(f"Local file not found: {local_file}")
|
196
196
|
|
197
197
|
with open(file=local_file, mode="rb") as fp:
|
198
|
-
header = {
|
198
|
+
header = {AppMeshClient.HTTP_HEADER_KEY_X_FILE_PATH: remote_file, "Content-Type": "text/plain"}
|
199
199
|
header[self.HTTP_HEADER_KEY_X_SEND_FILE_SOCKET] = "true"
|
200
200
|
|
201
201
|
if apply_file_attributes:
|
202
202
|
file_stat = os.stat(local_file)
|
203
|
-
header["File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
|
204
|
-
header["File-User"] = str(file_stat.st_uid)
|
205
|
-
header["File-Group"] = str(file_stat.st_gid)
|
203
|
+
header["X-File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
|
204
|
+
header["X-File-User"] = str(file_stat.st_uid)
|
205
|
+
header["X-File-Group"] = str(file_stat.st_gid)
|
206
206
|
|
207
207
|
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/file/upload", header=header)
|
208
208
|
|
appmesh/tcp_transport.py
CHANGED
@@ -31,9 +31,9 @@ class TCPTransport:
|
|
31
31
|
**Note**: Unlike HTTP requests, TCP connections cannot automatically retrieve intermediate or public CA certificates.
|
32
32
|
When `rest_ssl_verify` is a path, it explicitly identifies a CA issuer to ensure certificate validation.
|
33
33
|
|
34
|
-
ssl_client_cert (Union[str, Tuple[str, str]], optional): Path to the SSL client certificate and key.
|
35
|
-
|
36
|
-
|
34
|
+
ssl_client_cert (Union[str, Tuple[str, str]], optional): Path to the SSL client certificate and key. Can be:
|
35
|
+
- `str`: A path to a single PEM file containing both the client certificate and private key.
|
36
|
+
- `tuple`: A pair of paths (`cert_file`, `key_file`), where `cert_file` is the client certificate file path and `key_file` is the private key file path.
|
37
37
|
|
38
38
|
tcp_address (Tuple[str, int], optional): Address and port for establishing a TCP connection to the server.
|
39
39
|
Defaults to `("localhost", 6059)`.
|
@@ -83,21 +83,31 @@ class TCPTransport:
|
|
83
83
|
|
84
84
|
if self.ssl_client_cert is not None:
|
85
85
|
# Load client-side certificate and private key
|
86
|
-
|
86
|
+
if isinstance(self.ssl_client_cert, tuple) and len(self.ssl_client_cert) == 2:
|
87
|
+
context.load_cert_chain(certfile=self.ssl_client_cert[0], keyfile=self.ssl_client_cert[1])
|
88
|
+
elif isinstance(self.ssl_client_cert, str):
|
89
|
+
# Handle case where cert and key are in the same file
|
90
|
+
context.load_cert_chain(certfile=self.ssl_client_cert)
|
91
|
+
else:
|
92
|
+
raise ValueError("ssl_client_cert must be a string filepath or a tuple of (cert_file, key_file)")
|
87
93
|
|
88
94
|
# Create a TCP socket
|
89
95
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
90
96
|
sock.setblocking(True)
|
91
97
|
sock.settimeout(30) # Connection timeout set to 30 seconds
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
98
|
+
try:
|
99
|
+
# Wrap the socket with SSL/TLS
|
100
|
+
ssl_socket = context.wrap_socket(sock, server_hostname=self.tcp_address[0])
|
101
|
+
# Connect to the server
|
102
|
+
ssl_socket.connect(self.tcp_address)
|
103
|
+
# Disable Nagle's algorithm
|
104
|
+
ssl_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
105
|
+
# After connecting, set separate timeout for recv/send
|
106
|
+
# ssl_socket.settimeout(20) # 20 seconds for recv/send
|
107
|
+
self._socket = ssl_socket
|
108
|
+
except (socket.error, ssl.SSLError) as e:
|
109
|
+
sock.close()
|
110
|
+
raise RuntimeError(f"Failed to connect to {self.tcp_address}: {e}")
|
101
111
|
|
102
112
|
def close(self) -> None:
|
103
113
|
"""Close socket connection"""
|
@@ -115,23 +125,37 @@ class TCPTransport:
|
|
115
125
|
|
116
126
|
def send_message(self, data) -> None:
|
117
127
|
"""Send a message with a prefixed header indicating its length"""
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
128
|
+
if self._socket is None:
|
129
|
+
raise RuntimeError("Cannot send message: not connected")
|
130
|
+
|
131
|
+
try:
|
132
|
+
length = len(data)
|
133
|
+
# Pack the header into 8 bytes using big-endian format
|
134
|
+
self._socket.sendall(struct.pack("!II", self.TCP_MESSAGE_MAGIC, length))
|
135
|
+
if length > 0:
|
136
|
+
self._socket.sendall(data)
|
137
|
+
except (socket.error, ssl.SSLError) as e:
|
138
|
+
self.close()
|
139
|
+
raise RuntimeError(f"Error sending message: {e}")
|
123
140
|
|
124
141
|
def receive_message(self) -> Optional[bytearray]:
|
125
142
|
"""Receive a message with a prefixed header indicating its length and validate it"""
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
143
|
+
if self._socket is None:
|
144
|
+
raise RuntimeError("Cannot receive message: not connected")
|
145
|
+
|
146
|
+
try:
|
147
|
+
# Unpack the data (big-endian format)
|
148
|
+
magic, length = struct.unpack("!II", self._recvall(self.TCP_MESSAGE_HEADER_LENGTH))
|
149
|
+
if magic != self.TCP_MESSAGE_MAGIC:
|
150
|
+
raise ValueError(f"Invalid message: incorrect magic number 0x{magic:X}.")
|
151
|
+
if length > self.TCP_MAX_BLOCK_SIZE:
|
152
|
+
raise ValueError(f"Message size {length} exceeds the maximum allowed size of {self.TCP_MAX_BLOCK_SIZE} bytes.")
|
153
|
+
if length > 0:
|
154
|
+
return self._recvall(length)
|
155
|
+
return None
|
156
|
+
except (socket.error, ssl.SSLError) as e:
|
157
|
+
self.close()
|
158
|
+
raise RuntimeError(f"Error receiving message: {e}")
|
135
159
|
|
136
160
|
def _recvall(self, length: int) -> bytearray:
|
137
161
|
"""socket recv data with fixed length
|
@@ -156,11 +180,14 @@ class TCPTransport:
|
|
156
180
|
|
157
181
|
while bytes_received < length:
|
158
182
|
# Use recv_into to read directly into our buffer
|
159
|
-
|
183
|
+
try:
|
184
|
+
chunk_size = self._socket.recv_into(view[bytes_received:], length - bytes_received)
|
160
185
|
|
161
|
-
|
162
|
-
|
186
|
+
if chunk_size == 0:
|
187
|
+
raise EOFError("Connection closed by peer")
|
163
188
|
|
164
|
-
|
189
|
+
bytes_received += chunk_size
|
190
|
+
except socket.timeout:
|
191
|
+
raise socket.timeout(f"Socket operation timed out after receiving {bytes_received}/{length} bytes")
|
165
192
|
|
166
193
|
return buffer
|
@@ -0,0 +1,13 @@
|
|
1
|
+
appmesh/__init__.py,sha256=vgiSdMzlzDwgHxBMDoFaKWb77g2nJVciRf4z_ssAlwE,431
|
2
|
+
appmesh/app.py,sha256=9Q-SOOej-MH13BU5Dv2iTa-p-sECCJQp6ZX9DjWWmwE,10526
|
3
|
+
appmesh/app_output.py,sha256=JK_TMKgjvaw4n_ys_vmN5S4MyWVZpmD7NlKz_UyMIM8,1015
|
4
|
+
appmesh/app_run.py,sha256=9ISKGZ3k3kkbQvSsPfRfkOLqD9xhbqNOM7ork9F4w9c,1712
|
5
|
+
appmesh/appmesh_client.py,sha256=0ltkqHZUq094gKneYmC0bEZCP0X9kHTp9fccKdWFWP0,339
|
6
|
+
appmesh/http_client.py,sha256=2qrJYd9MpPZz5RNWTQIgxAFmHjn0rNsxLnKt1Ajn7hU,55810
|
7
|
+
appmesh/tcp_client.py,sha256=Id1aIKVWncTSZiKRVa4sgwo1tFX2wRqOLiTnI9-dNkE,11001
|
8
|
+
appmesh/tcp_messages.py,sha256=w1Kehz_aX4X2CYAUsy9mFVJRhxnLQwwc6L58W4YkQqs,969
|
9
|
+
appmesh/tcp_transport.py,sha256=-XDTQbsKL3yCbguHeW2jNqXpYgnCyHsH4rwcaJ46AS8,8645
|
10
|
+
appmesh-1.5.0.dist-info/METADATA,sha256=AsGZC2UcCoZNNn-fPmKnP8rmGujy65aqvnPxKH6Ix1Y,11663
|
11
|
+
appmesh-1.5.0.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
|
12
|
+
appmesh-1.5.0.dist-info/top_level.txt,sha256=-y0MNQOGJxUzLdHZ6E_Rfv5_LNCkV-GTmOBME_b6pg8,8
|
13
|
+
appmesh-1.5.0.dist-info/RECORD,,
|
appmesh/keycloak.py
DELETED
@@ -1,232 +0,0 @@
|
|
1
|
-
# Keycloak authentication client for App Mesh Python SDK
|
2
|
-
|
3
|
-
import base64
|
4
|
-
import json
|
5
|
-
import time
|
6
|
-
from typing import Dict, Optional, Tuple, Any
|
7
|
-
import requests
|
8
|
-
|
9
|
-
|
10
|
-
class KeycloakClient:
|
11
|
-
"""
|
12
|
-
Client for authenticating with Keycloak and obtaining tokens for App Mesh.
|
13
|
-
|
14
|
-
This class handles the OAuth2/OIDC workflow with Keycloak, including:
|
15
|
-
- Direct authentication with username/password
|
16
|
-
- Token refresh
|
17
|
-
- Token validation
|
18
|
-
"""
|
19
|
-
|
20
|
-
def __init__(
|
21
|
-
self,
|
22
|
-
server_url: str,
|
23
|
-
realm: str,
|
24
|
-
client_id: str,
|
25
|
-
client_secret: Optional[str] = None,
|
26
|
-
ssl_verify: bool = True,
|
27
|
-
timeout: Tuple[int, int] = (10, 60),
|
28
|
-
token_refresh_threshold: int = 30,
|
29
|
-
):
|
30
|
-
"""Initialize Keycloak client.
|
31
|
-
|
32
|
-
Args:
|
33
|
-
server_url (str): Keycloak server URL (e.g. https://keycloak.example.com/auth)
|
34
|
-
realm (str): Keycloak realm name
|
35
|
-
client_id (str): Client ID registered in Keycloak
|
36
|
-
client_secret (Optional[str], optional): Client secret if using confidential client. Defaults to None.
|
37
|
-
ssl_verify (bool, optional): Verify SSL certificates. Defaults to True.
|
38
|
-
timeout (Tuple[int, int], optional): Connection and read timeouts. Defaults to (10, 60).
|
39
|
-
token_refresh_threshold (int, optional): Seconds before token expiry to trigger refresh. Defaults to 30.
|
40
|
-
"""
|
41
|
-
self.server_url = server_url.rstrip("/")
|
42
|
-
self.realm = realm
|
43
|
-
self.client_id = client_id
|
44
|
-
self.client_secret = client_secret
|
45
|
-
self.ssl_verify = ssl_verify
|
46
|
-
self.timeout = timeout
|
47
|
-
self.token_refresh_threshold = token_refresh_threshold
|
48
|
-
|
49
|
-
# Token storage
|
50
|
-
self.access_token = None
|
51
|
-
self.refresh_token = None
|
52
|
-
self.token_expires_at = 0
|
53
|
-
|
54
|
-
# Construct the token endpoint URL
|
55
|
-
self.token_endpoint = f"{self.server_url}/realms/{self.realm}/protocol/openid-connect/token"
|
56
|
-
self.userinfo_endpoint = f"{self.server_url}/realms/{self.realm}/protocol/openid-connect/userinfo"
|
57
|
-
|
58
|
-
# Create a session for connection pooling
|
59
|
-
self.session = requests.Session()
|
60
|
-
|
61
|
-
def authenticate(self, username: str, password: str) -> str:
|
62
|
-
"""Authenticate with username and password.
|
63
|
-
|
64
|
-
Args:
|
65
|
-
username (str): Username
|
66
|
-
password (str): Password
|
67
|
-
|
68
|
-
Returns:
|
69
|
-
str: Access token
|
70
|
-
|
71
|
-
Raises:
|
72
|
-
Exception: If authentication fails
|
73
|
-
"""
|
74
|
-
data = {
|
75
|
-
"client_id": self.client_id,
|
76
|
-
"grant_type": "password",
|
77
|
-
"username": username,
|
78
|
-
"password": password,
|
79
|
-
}
|
80
|
-
|
81
|
-
if self.client_secret:
|
82
|
-
data["client_secret"] = self.client_secret
|
83
|
-
|
84
|
-
try:
|
85
|
-
response = self.session.post(self.token_endpoint, data=data, verify=self.ssl_verify, timeout=self.timeout)
|
86
|
-
|
87
|
-
if response.status_code != 200:
|
88
|
-
raise Exception(f"Authentication failed: {response.text}")
|
89
|
-
|
90
|
-
token_data = response.json()
|
91
|
-
self._update_token_data(token_data)
|
92
|
-
return self.access_token
|
93
|
-
|
94
|
-
except requests.RequestException as e:
|
95
|
-
raise Exception(f"Failed to connect to Keycloak: {str(e)}")
|
96
|
-
|
97
|
-
def refresh_tokens(self) -> str:
|
98
|
-
"""Refresh the access token using the refresh token.
|
99
|
-
|
100
|
-
Returns:
|
101
|
-
str: New access token
|
102
|
-
|
103
|
-
Raises:
|
104
|
-
Exception: If refresh fails
|
105
|
-
"""
|
106
|
-
if not self.refresh_token:
|
107
|
-
raise Exception("No refresh token available")
|
108
|
-
|
109
|
-
data = {
|
110
|
-
"client_id": self.client_id,
|
111
|
-
"grant_type": "refresh_token",
|
112
|
-
"refresh_token": self.refresh_token,
|
113
|
-
}
|
114
|
-
|
115
|
-
if self.client_secret:
|
116
|
-
data["client_secret"] = self.client_secret
|
117
|
-
|
118
|
-
try:
|
119
|
-
response = self.session.post(self.token_endpoint, data=data, verify=self.ssl_verify, timeout=self.timeout)
|
120
|
-
|
121
|
-
if response.status_code != 200:
|
122
|
-
raise Exception(f"Token refresh failed: {response.text}")
|
123
|
-
|
124
|
-
token_data = response.json()
|
125
|
-
self._update_token_data(token_data)
|
126
|
-
return self.access_token
|
127
|
-
|
128
|
-
except requests.RequestException as e:
|
129
|
-
raise Exception(f"Failed to connect to Keycloak: {str(e)}")
|
130
|
-
|
131
|
-
def validate_token(self) -> bool:
|
132
|
-
"""Check if the current token is valid and not expired.
|
133
|
-
|
134
|
-
Returns:
|
135
|
-
bool: True if valid, False otherwise
|
136
|
-
"""
|
137
|
-
if not self.access_token:
|
138
|
-
return False
|
139
|
-
|
140
|
-
# Check if the token is expired based on our local expiry time
|
141
|
-
if time.time() > self.token_expires_at:
|
142
|
-
return False
|
143
|
-
|
144
|
-
return True
|
145
|
-
|
146
|
-
def get_active_token(self) -> str:
|
147
|
-
"""Get a valid access token, refreshing if necessary.
|
148
|
-
|
149
|
-
Returns:
|
150
|
-
str: Valid access token
|
151
|
-
|
152
|
-
Raises:
|
153
|
-
Exception: If no valid token can be obtained
|
154
|
-
"""
|
155
|
-
if self.validate_token():
|
156
|
-
return self.access_token
|
157
|
-
|
158
|
-
if self.refresh_token:
|
159
|
-
try:
|
160
|
-
return self.refresh_tokens()
|
161
|
-
except Exception:
|
162
|
-
pass
|
163
|
-
|
164
|
-
raise Exception("No valid token available and unable to refresh")
|
165
|
-
|
166
|
-
def get_user_info(self) -> Dict[str, Any]:
|
167
|
-
"""Get information about the authenticated user.
|
168
|
-
|
169
|
-
Returns:
|
170
|
-
Dict[str, Any]: User information
|
171
|
-
|
172
|
-
Raises:
|
173
|
-
Exception: If request fails
|
174
|
-
"""
|
175
|
-
if not self.access_token:
|
176
|
-
raise Exception("Not authenticated")
|
177
|
-
|
178
|
-
headers = {"Authorization": f"Bearer {self.access_token}"}
|
179
|
-
|
180
|
-
try:
|
181
|
-
response = self.session.get(self.userinfo_endpoint, headers=headers, verify=self.ssl_verify, timeout=self.timeout)
|
182
|
-
|
183
|
-
if response.status_code != 200:
|
184
|
-
raise Exception(f"Failed to get user info: {response.text}")
|
185
|
-
|
186
|
-
return response.json()
|
187
|
-
|
188
|
-
except requests.RequestException as e:
|
189
|
-
raise Exception(f"Failed to connect to Keycloak: {str(e)}")
|
190
|
-
|
191
|
-
def _update_token_data(self, token_data: Dict[str, Any]) -> None:
|
192
|
-
"""Update the stored token data.
|
193
|
-
|
194
|
-
Args:
|
195
|
-
token_data (Dict[str, Any]): Token data from Keycloak
|
196
|
-
"""
|
197
|
-
self.access_token = token_data["access_token"]
|
198
|
-
self.refresh_token = token_data.get("refresh_token")
|
199
|
-
|
200
|
-
# Calculate token expiration time with configurable buffer
|
201
|
-
expires_in = token_data.get("expires_in", 300)
|
202
|
-
self.token_expires_at = time.time() + expires_in - self.token_refresh_threshold
|
203
|
-
|
204
|
-
def close(self) -> None:
|
205
|
-
"""Close the session and release resources."""
|
206
|
-
if hasattr(self, 'session'):
|
207
|
-
self.session.close()
|
208
|
-
|
209
|
-
@staticmethod
|
210
|
-
def decode_token(token: str) -> Dict[str, Any]:
|
211
|
-
"""Decode a JWT token without verification.
|
212
|
-
|
213
|
-
Args:
|
214
|
-
token (str): JWT token
|
215
|
-
|
216
|
-
Returns:
|
217
|
-
Dict[str, Any]: Decoded token payload
|
218
|
-
"""
|
219
|
-
parts = token.split(".")
|
220
|
-
if len(parts) != 3:
|
221
|
-
raise Exception("Invalid token format")
|
222
|
-
|
223
|
-
# Decode the payload (second part)
|
224
|
-
payload = parts[1]
|
225
|
-
# Add padding if necessary
|
226
|
-
payload += "=" * (4 - len(payload) % 4) if len(payload) % 4 else ""
|
227
|
-
|
228
|
-
try:
|
229
|
-
decoded = base64.b64decode(payload)
|
230
|
-
return json.loads(decoded)
|
231
|
-
except Exception as e:
|
232
|
-
raise Exception(f"Failed to decode token: {str(e)}")
|
appmesh-1.4.7.dist-info/RECORD
DELETED
@@ -1,14 +0,0 @@
|
|
1
|
-
appmesh/__init__.py,sha256=vgiSdMzlzDwgHxBMDoFaKWb77g2nJVciRf4z_ssAlwE,431
|
2
|
-
appmesh/app.py,sha256=9Q-SOOej-MH13BU5Dv2iTa-p-sECCJQp6ZX9DjWWmwE,10526
|
3
|
-
appmesh/app_output.py,sha256=JK_TMKgjvaw4n_ys_vmN5S4MyWVZpmD7NlKz_UyMIM8,1015
|
4
|
-
appmesh/app_run.py,sha256=9ISKGZ3k3kkbQvSsPfRfkOLqD9xhbqNOM7ork9F4w9c,1712
|
5
|
-
appmesh/appmesh_client.py,sha256=0ltkqHZUq094gKneYmC0bEZCP0X9kHTp9fccKdWFWP0,339
|
6
|
-
appmesh/http_client.py,sha256=dtrZryrkJ-dmT_NQE5v0fZPE00-PCD9VB9xL1Wu8ORI,47403
|
7
|
-
appmesh/keycloak.py,sha256=siyifDaH3EASIti8s0DzxBo477m8eCRmzKwK6dqGRDY,7497
|
8
|
-
appmesh/tcp_client.py,sha256=RkHl5s8jE333BJOgxJqJ_fvjbdRQza7ciV49vLT6YO4,10923
|
9
|
-
appmesh/tcp_messages.py,sha256=w1Kehz_aX4X2CYAUsy9mFVJRhxnLQwwc6L58W4YkQqs,969
|
10
|
-
appmesh/tcp_transport.py,sha256=UMGby2oKV4k7lyXZUMSOe2Je34fb1w7nTkxEpatKLKg,7256
|
11
|
-
appmesh-1.4.7.dist-info/METADATA,sha256=Tinrv6mkVtpVQi-ZZm_rTpCWLu-NoTEUvL-dBAQVaZg,11663
|
12
|
-
appmesh-1.4.7.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
|
13
|
-
appmesh-1.4.7.dist-info/top_level.txt,sha256=-y0MNQOGJxUzLdHZ6E_Rfv5_LNCkV-GTmOBME_b6pg8,8
|
14
|
-
appmesh-1.4.7.dist-info/RECORD,,
|
File without changes
|