nextlayer-sdk-python 1.0.2__py3-none-any.whl → 1.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.
- nextlayer/sdk/__init__.py +1 -1
- nextlayer/sdk/auth.py +107 -67
- nextlayer/sdk/auth_code_browser_flow.py +130 -0
- nextlayer/sdk/auth_httpx.py +21 -2
- nextlayer/sdk/utils.py +19 -0
- {nextlayer_sdk_python-1.0.2.dist-info → nextlayer_sdk_python-1.2.0.dist-info}/METADATA +6 -4
- nextlayer_sdk_python-1.2.0.dist-info/RECORD +11 -0
- {nextlayer_sdk_python-1.0.2.dist-info → nextlayer_sdk_python-1.2.0.dist-info}/WHEEL +1 -1
- nextlayer_sdk_python-1.0.2.dist-info/RECORD +0 -9
- {nextlayer_sdk_python-1.0.2.dist-info → nextlayer_sdk_python-1.2.0.dist-info}/entry_points.txt +0 -0
nextlayer/sdk/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# do not edit this version line, it will be updated in CI-Pipeline!
|
|
2
|
-
__version__ = "v1.0
|
|
2
|
+
__version__ = "v1.2.0"
|
nextlayer/sdk/auth.py
CHANGED
|
@@ -14,10 +14,7 @@ from keycloak import KeycloakOpenID
|
|
|
14
14
|
from keycloak.exceptions import KeycloakGetError
|
|
15
15
|
from pydantic import BaseModel, Field
|
|
16
16
|
|
|
17
|
-
from . import errors
|
|
18
|
-
|
|
19
|
-
# Retrieve/Refresh Token on demand from next layer IAM
|
|
20
|
-
|
|
17
|
+
from . import auth_code_browser_flow, errors, utils
|
|
21
18
|
|
|
22
19
|
log = logging.getLogger(__name__)
|
|
23
20
|
|
|
@@ -26,21 +23,8 @@ DEFAULT_CLIENT_ID = "nextlayer-sdk-python"
|
|
|
26
23
|
DEFAULT_REALM = "nlcustomers"
|
|
27
24
|
|
|
28
25
|
|
|
29
|
-
def _get_current_user_homedir() -> str:
|
|
30
|
-
try:
|
|
31
|
-
# this is the reliable/correct method, that also works in cases
|
|
32
|
-
# where e.g. process-manager like gunicorn changes the efective UID
|
|
33
|
-
# but preserves the environment
|
|
34
|
-
import pwd
|
|
35
|
-
|
|
36
|
-
return pwd.getpwuid(os.getuid()).pw_dir
|
|
37
|
-
except Exception:
|
|
38
|
-
# fallback for (non-unix) platforms without `pwd` module
|
|
39
|
-
return os.path.expanduser("~")
|
|
40
|
-
|
|
41
|
-
|
|
42
26
|
def _get_default_config_filename(realm: str) -> str:
|
|
43
|
-
homedir =
|
|
27
|
+
homedir = utils.get_current_user_homedir()
|
|
44
28
|
default_fn = os.path.join(homedir, ".nextlayer-sdk", f"auth.{realm}.yml")
|
|
45
29
|
if os.path.exists(default_fn):
|
|
46
30
|
return default_fn
|
|
@@ -51,23 +35,26 @@ def _get_default_config_filename(realm: str) -> str:
|
|
|
51
35
|
|
|
52
36
|
|
|
53
37
|
class PersistentConfig(BaseModel):
|
|
38
|
+
"""persisted data (usually stored in YAML file)"""
|
|
39
|
+
|
|
40
|
+
# user defined config values (and their defaults):
|
|
54
41
|
server_url: str = DEFAULT_KEYCLOAK_URL
|
|
55
42
|
realm_name: str = DEFAULT_REALM
|
|
56
43
|
client_id: str = DEFAULT_CLIENT_ID
|
|
57
44
|
username: str | None = None
|
|
58
45
|
password: str | None = None
|
|
46
|
+
extra_params: dict[str, Any] = Field(default_factory=dict)
|
|
59
47
|
|
|
48
|
+
# state updated by NlAuth itself:
|
|
60
49
|
expires_at: float | None = None
|
|
61
50
|
refresh_expires_at: float | None = None
|
|
62
51
|
tokens: Any | None = None
|
|
63
52
|
|
|
64
|
-
extra_params: dict[str, Any] = Field(default_factory=dict)
|
|
65
|
-
|
|
66
53
|
|
|
67
54
|
class FilePerRealmAuthStore:
|
|
68
|
-
def __init__(self, filename: str):
|
|
55
|
+
def __init__(self, filename: str, config: PersistentConfig):
|
|
69
56
|
self.config_filename: str = filename
|
|
70
|
-
self.config =
|
|
57
|
+
self.config = config
|
|
71
58
|
self.config_read_mtime = 0
|
|
72
59
|
|
|
73
60
|
self.read_config()
|
|
@@ -84,16 +71,18 @@ class FilePerRealmAuthStore:
|
|
|
84
71
|
|
|
85
72
|
def read_config(self):
|
|
86
73
|
if not os.path.isfile(self.config_filename):
|
|
87
|
-
self.config =
|
|
74
|
+
self.config = self.config.__class__()
|
|
88
75
|
return
|
|
89
76
|
log.debug(f"reading config from {self.config_filename}")
|
|
90
77
|
self.config_read_mtime = self.mtime
|
|
91
78
|
with open(self.config_filename) as fil:
|
|
92
|
-
self.config =
|
|
79
|
+
self.config = self.config.__class__.model_validate(
|
|
80
|
+
yaml.safe_load(fil) or {}
|
|
81
|
+
)
|
|
93
82
|
|
|
94
83
|
def write_config(self):
|
|
95
84
|
log.debug(f"writing config to {self.config_filename}")
|
|
96
|
-
# in the
|
|
85
|
+
# in the YAML we only want to store keys that have been explicitly
|
|
97
86
|
# set to some (non-default) value
|
|
98
87
|
data = self.config.model_dump(
|
|
99
88
|
exclude_none=True, exclude_unset=True, exclude_defaults=True
|
|
@@ -119,23 +108,40 @@ class FilePerRealmAuthStore:
|
|
|
119
108
|
class NlAuth(object):
|
|
120
109
|
def __init__(
|
|
121
110
|
self,
|
|
122
|
-
config_filename=None,
|
|
123
|
-
server_url=None,
|
|
124
|
-
realm_name=None,
|
|
125
|
-
client_id=None,
|
|
126
|
-
username=None,
|
|
111
|
+
config_filename: str | None = None,
|
|
112
|
+
server_url: str | None = None,
|
|
113
|
+
realm_name: str | None = None,
|
|
114
|
+
client_id: str | None = None,
|
|
115
|
+
username: str | None = None,
|
|
116
|
+
ask_totp=False,
|
|
117
|
+
browser_login=False,
|
|
118
|
+
# optional customizations of pre-defined defaults:
|
|
119
|
+
config_defaults: PersistentConfig | None = None,
|
|
120
|
+
browser_login_redirect_uri: str | None = None,
|
|
121
|
+
username_envvar="NEXTLAYERSDK_USERNAME",
|
|
122
|
+
password_envvar="NEXTLAYERSDK_PASSWORD",
|
|
127
123
|
):
|
|
124
|
+
if config_defaults is None:
|
|
125
|
+
config_defaults = PersistentConfig()
|
|
126
|
+
|
|
127
|
+
self.username_envvar = username_envvar
|
|
128
|
+
self.password_envvar = password_envvar
|
|
129
|
+
|
|
130
|
+
self.ask_totp = ask_totp
|
|
131
|
+
self.browser_login = browser_login
|
|
132
|
+
self.browser_login_redirect_uri = browser_login_redirect_uri
|
|
133
|
+
|
|
128
134
|
self.config_filename = config_filename or _get_default_config_filename(
|
|
129
|
-
realm_name or
|
|
135
|
+
realm_name or config_defaults.realm_name
|
|
130
136
|
)
|
|
131
|
-
self.store = FilePerRealmAuthStore(self.config_filename)
|
|
137
|
+
self.store = FilePerRealmAuthStore(self.config_filename, config_defaults)
|
|
132
138
|
|
|
133
139
|
new_server_url = (server_url or self.store.config.server_url).rstrip("/") + "/"
|
|
134
140
|
new_realm_name = realm_name or self.store.config.realm_name
|
|
135
141
|
new_client_id = client_id or self.store.config.client_id
|
|
136
142
|
self.username = (
|
|
137
143
|
username
|
|
138
|
-
or os.environ.get(
|
|
144
|
+
or os.environ.get(self.username_envvar)
|
|
139
145
|
or self.store.config.username
|
|
140
146
|
)
|
|
141
147
|
|
|
@@ -199,40 +205,53 @@ class NlAuth(object):
|
|
|
199
205
|
log.debug(
|
|
200
206
|
f"do_login for realm {self.store.config.realm_name} on {self.store.config.server_url}"
|
|
201
207
|
)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
208
|
+
if self.browser_login:
|
|
209
|
+
tokens = auth_code_browser_flow.start_authorization_flow(
|
|
210
|
+
self.keycloak_openid,
|
|
211
|
+
redirect_uri=self.browser_login_redirect_uri,
|
|
212
|
+
)
|
|
213
|
+
else:
|
|
214
|
+
password = (
|
|
215
|
+
password
|
|
216
|
+
or os.environ.get(self.password_envvar)
|
|
217
|
+
or self.store.config.password
|
|
218
|
+
)
|
|
207
219
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
220
|
+
totp_secret: int | None = None
|
|
221
|
+
|
|
222
|
+
# Interactive:
|
|
223
|
+
if (not self.username or not password) and sys.stdin.isatty():
|
|
224
|
+
if not self.username:
|
|
225
|
+
self.username = getpass.getuser()
|
|
226
|
+
if not password:
|
|
227
|
+
sys.stderr.write(
|
|
228
|
+
"# Note: If you want to log in with another username, just press Enter.\n"
|
|
229
|
+
"# The password you enter will be used once to obtain new\n"
|
|
230
|
+
"# tokens from IAM and will not be stored anywhere. Once the\n"
|
|
231
|
+
"# refresh token expires, you will need to enter it again.\n"
|
|
232
|
+
)
|
|
233
|
+
password = getpass.getpass(f"Password for {self.username}: ")
|
|
234
|
+
if not password:
|
|
235
|
+
self.username = input("Username: ")
|
|
236
|
+
if self.username:
|
|
237
|
+
self.store.config.username = self.username
|
|
238
|
+
self.store.write_config()
|
|
239
|
+
password = getpass.getpass(f"Password for {self.username}: ")
|
|
240
|
+
if self.ask_totp:
|
|
241
|
+
totp_secret = int(input("TOTP/2FA secret: "))
|
|
242
|
+
|
|
243
|
+
if not self.username or not password:
|
|
244
|
+
raise errors.AuthenticationError(
|
|
245
|
+
f"failed to set {self.username_envvar}/{self.password_envvar} from config,env-var,interactive"
|
|
218
246
|
)
|
|
219
|
-
password = getpass.getpass(f"Password for {self.username}: ")
|
|
220
|
-
if not password:
|
|
221
|
-
self.username = input("Username: ")
|
|
222
|
-
if self.username:
|
|
223
|
-
self.store.config.username = self.username
|
|
224
|
-
self.store.write_config()
|
|
225
|
-
password = getpass.getpass(f"Password for {self.username}: ")
|
|
226
|
-
|
|
227
|
-
if not self.username or not password:
|
|
228
|
-
raise errors.AuthenticationError(
|
|
229
|
-
"failed to set NEXTLAYERSDK_USERNAME/PASSWORD from config,env-var,interactive"
|
|
230
|
-
)
|
|
231
247
|
|
|
232
|
-
|
|
248
|
+
extra = self.store.config.extra_params
|
|
249
|
+
|
|
250
|
+
log.debug(f"make keycloak token-request for user {self.username}")
|
|
251
|
+
tokens = self.keycloak_openid.token(
|
|
252
|
+
self.username, password, totp=totp_secret, **extra
|
|
253
|
+
)
|
|
233
254
|
|
|
234
|
-
log.debug(f"make keycloak token-request for user {self.username}")
|
|
235
|
-
tokens = self.keycloak_openid.token(self.username, password, **extra)
|
|
236
255
|
access_token = self.update_tokens(tokens)
|
|
237
256
|
self.store.write_config()
|
|
238
257
|
return access_token
|
|
@@ -241,10 +260,10 @@ class NlAuth(object):
|
|
|
241
260
|
try:
|
|
242
261
|
log.debug("make keycloak token-refresh")
|
|
243
262
|
tokens = self.keycloak_openid.refresh_token(
|
|
244
|
-
self.store.config.tokens
|
|
263
|
+
(self.store.config.tokens or {}).get("refresh_token") or ""
|
|
245
264
|
)
|
|
246
265
|
except KeycloakGetError as e:
|
|
247
|
-
# e.g
|
|
266
|
+
# e.g., 400: b'{"error":"invalid_grant", "error_description":"Session not active"}'
|
|
248
267
|
# ... when session has been deleted in Keycloak and refresh_token cannot be used anymore
|
|
249
268
|
sys.stderr.write("token refresh failed: %s\n" % (e,))
|
|
250
269
|
return self.do_login()
|
|
@@ -265,7 +284,9 @@ class NlAuth(object):
|
|
|
265
284
|
elif self.token_expired():
|
|
266
285
|
access_token = self.do_refresh()
|
|
267
286
|
else:
|
|
268
|
-
access_token = self.store.config.tokens
|
|
287
|
+
access_token = (self.store.config.tokens or {}).get(
|
|
288
|
+
"access_token"
|
|
289
|
+
) or ""
|
|
269
290
|
return access_token
|
|
270
291
|
except errors.NextlayerSdkError:
|
|
271
292
|
raise
|
|
@@ -276,12 +297,17 @@ class NlAuth(object):
|
|
|
276
297
|
access_token = self.get_access_token()
|
|
277
298
|
|
|
278
299
|
log.debug("obtaining certs from keycloak for token-verification")
|
|
279
|
-
|
|
300
|
+
info = jwt.decode(
|
|
280
301
|
access_token,
|
|
281
302
|
key=jwt.PyJWK(self.keycloak_openid.certs()["keys"][0]),
|
|
282
303
|
options=dict(verify_aud=False),
|
|
283
304
|
algorithms=["HS256", "RS256"],
|
|
284
305
|
)
|
|
306
|
+
access_token_remaining_seconds = info["exp"] - time.time()
|
|
307
|
+
log.debug(
|
|
308
|
+
f"access token expires in {access_token_remaining_seconds:.0f} seconds"
|
|
309
|
+
)
|
|
310
|
+
return info
|
|
285
311
|
|
|
286
312
|
|
|
287
313
|
def parse_commandline_args():
|
|
@@ -307,6 +333,18 @@ def parse_commandline_args():
|
|
|
307
333
|
)
|
|
308
334
|
parser.add_argument("-u", "--username", help="username")
|
|
309
335
|
parser.add_argument("-a", "--aud", help="set audience - default: not set")
|
|
336
|
+
parser.add_argument(
|
|
337
|
+
"-t",
|
|
338
|
+
"--totp",
|
|
339
|
+
action="store_true",
|
|
340
|
+
help="ask for TOTP/2FA secret on interactive login",
|
|
341
|
+
)
|
|
342
|
+
parser.add_argument(
|
|
343
|
+
"-b",
|
|
344
|
+
"--browser",
|
|
345
|
+
action="store_true",
|
|
346
|
+
help="use authorization code flow with browser",
|
|
347
|
+
)
|
|
310
348
|
parser.add_argument("-v", "--verbose", action="store_true")
|
|
311
349
|
parser.add_argument(
|
|
312
350
|
"command",
|
|
@@ -333,6 +371,8 @@ def main() -> int:
|
|
|
333
371
|
realm_name=args.realm,
|
|
334
372
|
client_id=args.client_id,
|
|
335
373
|
username=args.username,
|
|
374
|
+
ask_totp=args.totp,
|
|
375
|
+
browser_login=args.browser,
|
|
336
376
|
)
|
|
337
377
|
|
|
338
378
|
if args.aud:
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
import webbrowser
|
|
8
|
+
from urllib.parse import parse_qs, urlparse
|
|
9
|
+
|
|
10
|
+
from keycloak import KeycloakOpenID
|
|
11
|
+
|
|
12
|
+
from nextlayer.sdk.errors import AuthenticationError
|
|
13
|
+
|
|
14
|
+
from . import utils
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def start_authorization_flow(k_client: KeycloakOpenID, redirect_uri: str | None = None):
|
|
20
|
+
"""
|
|
21
|
+
Starts the Browser-based Authorization Code Flow with State, Nonce and PKCE.
|
|
22
|
+
"""
|
|
23
|
+
if redirect_uri is None:
|
|
24
|
+
redirect_uri = "https://login-callback.nextlayer.at/"
|
|
25
|
+
|
|
26
|
+
if not sys.stdin.isatty():
|
|
27
|
+
raise AuthenticationError(
|
|
28
|
+
"Cannot start browser based authorization flow in non-interactive mode."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Generate security parameters
|
|
32
|
+
state = utils.generate_random_string(16)
|
|
33
|
+
nonce = utils.generate_random_string(16)
|
|
34
|
+
|
|
35
|
+
# PKCE Params
|
|
36
|
+
# 1. Code Verifier (random String, 43-128 characters)
|
|
37
|
+
code_verifier = utils.generate_random_string(64)
|
|
38
|
+
# 2. Code Challenge (SHA256 Hash of Verifier, Base64 urlsafe)
|
|
39
|
+
_hashed_verifier = hashlib.sha256(code_verifier.encode("utf-8")).digest()
|
|
40
|
+
code_challenge = (
|
|
41
|
+
base64.urlsafe_b64encode(_hashed_verifier).rstrip(b"=").decode("utf-8")
|
|
42
|
+
)
|
|
43
|
+
code_challenge_method = "S256"
|
|
44
|
+
|
|
45
|
+
# generate Auth URL
|
|
46
|
+
auth_url = k_client.auth_url(
|
|
47
|
+
redirect_uri=redirect_uri,
|
|
48
|
+
scope="openid",
|
|
49
|
+
state=state, # CSRF Protection
|
|
50
|
+
nonce=nonce, # OIDC Protection
|
|
51
|
+
# code_challenge=code_challenge,
|
|
52
|
+
# code_challenge_method=code_challenge_method,
|
|
53
|
+
)
|
|
54
|
+
# not yet supported by the python-keycloak library, so we need to add PKCE params manually
|
|
55
|
+
auth_url += f"&code_challenge={code_challenge}&code_challenge_method={code_challenge_method}"
|
|
56
|
+
|
|
57
|
+
sys.stderr.write("\n1. Please open the following URL in your browser to log in:")
|
|
58
|
+
sys.stderr.write("-" * 70 + "\n")
|
|
59
|
+
sys.stderr.write(auth_url + "\n")
|
|
60
|
+
sys.stderr.write("-" * 70 + "\n")
|
|
61
|
+
|
|
62
|
+
if platform.system() == "Windows" or os.environ.get("DISPLAY"):
|
|
63
|
+
# open the URL in the default browser on supported platforms
|
|
64
|
+
try:
|
|
65
|
+
webbrowser.open_new_tab(auth_url)
|
|
66
|
+
sys.stderr.write("The login page was opened in your default browser.\n")
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
sys.stderr.write(
|
|
71
|
+
f"\n2. Please copy the complete redirect URL (starting with {redirect_uri}?) and paste it here:\n"
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
redirect_full_url = input("> ")
|
|
75
|
+
except (EOFError, KeyboardInterrupt):
|
|
76
|
+
sys.stderr.write("\nInput cancelled.\n")
|
|
77
|
+
raise AuthenticationError(
|
|
78
|
+
"No Callback-URL entered - Authorization flow aborted."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Parse the entered URL and extract State/Code
|
|
82
|
+
try:
|
|
83
|
+
parsed_url = urlparse(redirect_full_url)
|
|
84
|
+
query_params = parse_qs(parsed_url.query)
|
|
85
|
+
|
|
86
|
+
if "code" not in query_params or "state" not in query_params:
|
|
87
|
+
log.error("Code or State-Param missing in Redirect-URL.")
|
|
88
|
+
raise AuthenticationError("missing code or state-param in redirect URL")
|
|
89
|
+
|
|
90
|
+
# Verify the returned State against the initially sent
|
|
91
|
+
returned_state = query_params["state"][0]
|
|
92
|
+
if returned_state != state:
|
|
93
|
+
log.warning(
|
|
94
|
+
"SECURITY ERROR: The returned 'state' does not match the sent state."
|
|
95
|
+
)
|
|
96
|
+
log.debug(f"Sent: {state}, Received: {returned_state}")
|
|
97
|
+
raise AuthenticationError("Attack attempt detected (CSRF)")
|
|
98
|
+
|
|
99
|
+
auth_code = query_params["code"][0]
|
|
100
|
+
log.debug(
|
|
101
|
+
f"[OK] State verified and authorization code successfully received: {auth_code[:10]}..."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
except Exception as e:
|
|
105
|
+
log.error(f"ERROR parsing URL: {e}")
|
|
106
|
+
raise AuthenticationError("Invalid Redirect URL")
|
|
107
|
+
|
|
108
|
+
# Exchange code for token (with Code Verifier for PKCE)
|
|
109
|
+
log.debug("Exchanging code for Access Token (with PKCE Verifier)...")
|
|
110
|
+
try:
|
|
111
|
+
token_response = k_client.token(
|
|
112
|
+
grant_type="authorization_code",
|
|
113
|
+
code=auth_code,
|
|
114
|
+
redirect_uri=redirect_uri,
|
|
115
|
+
code_verifier=code_verifier, # key for PKCE
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
id_token = token_response.get("id_token") or ""
|
|
119
|
+
sys.stderr.write("\n--- SUCCESS! Token received ---\n")
|
|
120
|
+
|
|
121
|
+
# # Validate ID Token and verify Nonce
|
|
122
|
+
id_token_nonce = k_client.decode_token(id_token).get("nonce")
|
|
123
|
+
if id_token_nonce != nonce:
|
|
124
|
+
raise AuthenticationError("Nonce mismatch in ID Token!")
|
|
125
|
+
|
|
126
|
+
return token_response
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
log.error(f"ERROR during token exchange (Token endpoint call): {e}")
|
|
130
|
+
raise
|
nextlayer/sdk/auth_httpx.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import AsyncGenerator, Generator
|
|
2
3
|
|
|
3
4
|
import httpx
|
|
4
5
|
|
|
@@ -7,10 +8,28 @@ from .auth import NlAuth
|
|
|
7
8
|
|
|
8
9
|
class NlHttpxAuth(httpx.Auth):
|
|
9
10
|
def __init__(self, *args, **kwargs):
|
|
10
|
-
|
|
11
|
+
if len(args) == 1 and len(kwargs) == 0 and isinstance(args[0], NlAuth):
|
|
12
|
+
# pass an existing NlAuth instance directly
|
|
13
|
+
self.nlauth = args[0]
|
|
14
|
+
else:
|
|
15
|
+
# pass all arguments to NlAuth constructor and create NlAuth instance internally
|
|
16
|
+
self.nlauth = NlAuth(*args, **kwargs)
|
|
17
|
+
self._lock = asyncio.Lock()
|
|
11
18
|
|
|
12
19
|
def auth_flow(
|
|
13
20
|
self, request: httpx.Request
|
|
14
21
|
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
15
22
|
request.headers["Authorization"] = "Bearer " + self.nlauth.get_access_token()
|
|
16
23
|
yield request
|
|
24
|
+
|
|
25
|
+
async def async_auth_flow(
|
|
26
|
+
self, request: httpx.Request
|
|
27
|
+
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
28
|
+
# NlAuth.get_access_token() does blocking file I/O and, on refresh/login,
|
|
29
|
+
# a blocking HTTP call to Keycloak - offload it so it doesn't stall the
|
|
30
|
+
# event loop. The lock avoids concurrent coroutines all triggering
|
|
31
|
+
# redundant refresh/login calls when the token expires under load.
|
|
32
|
+
async with self._lock:
|
|
33
|
+
access_token = await asyncio.to_thread(self.nlauth.get_access_token)
|
|
34
|
+
request.headers["Authorization"] = "Bearer " + access_token
|
|
35
|
+
yield request
|
nextlayer/sdk/utils.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def generate_random_string(length=32):
|
|
6
|
+
return base64.urlsafe_b64encode(os.urandom(length)).rstrip(b"=").decode("utf-8")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_current_user_homedir() -> str:
|
|
10
|
+
try:
|
|
11
|
+
# this is the reliable/correct method that also works in cases
|
|
12
|
+
# where e.g., process-manager like gunicorn changes the effective UID
|
|
13
|
+
# but preserves the environment
|
|
14
|
+
import pwd
|
|
15
|
+
|
|
16
|
+
return pwd.getpwuid(os.getuid()).pw_dir
|
|
17
|
+
except Exception:
|
|
18
|
+
# fallback for (non-unix) platforms without `pwd` module
|
|
19
|
+
return os.path.expanduser("~")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: nextlayer-sdk-python
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Client utilities to interact with next layer public APIs
|
|
5
5
|
Author: Wolfgang Powisch
|
|
6
6
|
Author-email: wolfgang.powisch@nextlayer.at
|
|
@@ -11,10 +11,12 @@ Classifier: Programming Language :: Python :: 3
|
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
15
|
Requires-Dist: PyYAML (>=6.0,<7.0)
|
|
15
|
-
Requires-Dist:
|
|
16
|
+
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
17
|
+
Requires-Dist: pydantic (>=2.12.5,<3.0.0)
|
|
16
18
|
Requires-Dist: pyjwt (>=2.10.1,<3.0.0)
|
|
17
|
-
Requires-Dist: python-keycloak (>=
|
|
19
|
+
Requires-Dist: python-keycloak (>=5.8.1,<6.0.0)
|
|
18
20
|
Description-Content-Type: text/markdown
|
|
19
21
|
|
|
20
22
|
# nextlayer-sdk-python
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
nextlayer/sdk/__init__.py,sha256=EfLbD6-AXvk4rve6mPa9Gpf5QIau7TBrkkGJuOoWWu0,91
|
|
2
|
+
nextlayer/sdk/__init__.pye,sha256=fIfxryYAp41WbubhUjhitrpwoi6wXFQCZL40RdI4tHA,90
|
|
3
|
+
nextlayer/sdk/auth.py,sha256=i89o0NmSgR9gwntoihfQ_CqyH7iT_FS-w9vCqLMJV5k,13911
|
|
4
|
+
nextlayer/sdk/auth_code_browser_flow.py,sha256=luPScN2WFJtBzUc-6jEGx1zx3cjfgsl1UQweikD3y90,4648
|
|
5
|
+
nextlayer/sdk/auth_httpx.py,sha256=sW_wmzaWTFR6f-xQXfwHtBdNt4gsMdEcbBsKulnC9Ag,1398
|
|
6
|
+
nextlayer/sdk/errors.py,sha256=ZylQSsiFOulLaFwIeBHK0ZWo2Vn6AjML8PoNGIDdx8c,430
|
|
7
|
+
nextlayer/sdk/utils.py,sha256=JPkPuy1bU4jBOf0NunYWMqwrwKmNa2OKNTGPv_UaH6s,583
|
|
8
|
+
nextlayer_sdk_python-1.2.0.dist-info/METADATA,sha256=ADU3udM8afXB2nxiastSW6H8i08nJnxEGb8f6GihnvE,2906
|
|
9
|
+
nextlayer_sdk_python-1.2.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
10
|
+
nextlayer_sdk_python-1.2.0.dist-info/entry_points.txt,sha256=d2f4hShiG0Z_TDG72fCWJdOUZkXPfmPBUIieDXh94d0,58
|
|
11
|
+
nextlayer_sdk_python-1.2.0.dist-info/RECORD,,
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
nextlayer/sdk/__init__.py,sha256=_VmS9NyUbJ8iOKpO3uvo3DBilgklI2h2e5cj_HxuigA,91
|
|
2
|
-
nextlayer/sdk/__init__.pye,sha256=fIfxryYAp41WbubhUjhitrpwoi6wXFQCZL40RdI4tHA,90
|
|
3
|
-
nextlayer/sdk/auth.py,sha256=ldbaFrhKZ4MrJdnqynwKCBCxbiwzsOHLNPsBqx1unno,12197
|
|
4
|
-
nextlayer/sdk/auth_httpx.py,sha256=Grc5lbFBA8DF8nOPDlhEPjB0CtujN-aO9NaXUV7aN4g,411
|
|
5
|
-
nextlayer/sdk/errors.py,sha256=ZylQSsiFOulLaFwIeBHK0ZWo2Vn6AjML8PoNGIDdx8c,430
|
|
6
|
-
nextlayer_sdk_python-1.0.2.dist-info/METADATA,sha256=t-v2JwHxKPML2GA_I8-vnH0cEWsG6o67Usy0Vm7MePA,2815
|
|
7
|
-
nextlayer_sdk_python-1.0.2.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
8
|
-
nextlayer_sdk_python-1.0.2.dist-info/entry_points.txt,sha256=d2f4hShiG0Z_TDG72fCWJdOUZkXPfmPBUIieDXh94d0,58
|
|
9
|
-
nextlayer_sdk_python-1.0.2.dist-info/RECORD,,
|
{nextlayer_sdk_python-1.0.2.dist-info → nextlayer_sdk_python-1.2.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|