nextlayer-sdk-python 1.2.0__py3-none-any.whl → 1.3.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 +61 -7
- nextlayer/sdk/auth_httpx.py +6 -4
- {nextlayer_sdk_python-1.2.0.dist-info → nextlayer_sdk_python-1.3.0.dist-info}/METADATA +28 -1
- nextlayer_sdk_python-1.3.0.dist-info/RECORD +11 -0
- nextlayer_sdk_python-1.2.0.dist-info/RECORD +0 -11
- {nextlayer_sdk_python-1.2.0.dist-info → nextlayer_sdk_python-1.3.0.dist-info}/WHEEL +0 -0
- {nextlayer_sdk_python-1.2.0.dist-info → nextlayer_sdk_python-1.3.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.
|
|
2
|
+
__version__ = "v1.3.0"
|
nextlayer/sdk/auth.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
import asyncio
|
|
2
3
|
import datetime
|
|
3
4
|
import getpass
|
|
4
5
|
import json
|
|
@@ -11,7 +12,7 @@ from typing import Any
|
|
|
11
12
|
import jwt
|
|
12
13
|
import yaml
|
|
13
14
|
from keycloak import KeycloakOpenID
|
|
14
|
-
from keycloak.exceptions import
|
|
15
|
+
from keycloak.exceptions import KeycloakOperationError
|
|
15
16
|
from pydantic import BaseModel, Field
|
|
16
17
|
|
|
17
18
|
from . import auth_code_browser_flow, errors, utils
|
|
@@ -43,6 +44,7 @@ class PersistentConfig(BaseModel):
|
|
|
43
44
|
client_id: str = DEFAULT_CLIENT_ID
|
|
44
45
|
username: str | None = None
|
|
45
46
|
password: str | None = None
|
|
47
|
+
client_secret: str | None = None
|
|
46
48
|
extra_params: dict[str, Any] = Field(default_factory=dict)
|
|
47
49
|
|
|
48
50
|
# state updated by NlAuth itself:
|
|
@@ -113,19 +115,24 @@ class NlAuth(object):
|
|
|
113
115
|
realm_name: str | None = None,
|
|
114
116
|
client_id: str | None = None,
|
|
115
117
|
username: str | None = None,
|
|
118
|
+
client_secret: str | None = None,
|
|
116
119
|
ask_totp=False,
|
|
117
120
|
browser_login=False,
|
|
118
121
|
# optional customizations of pre-defined defaults:
|
|
119
122
|
config_defaults: PersistentConfig | None = None,
|
|
120
123
|
browser_login_redirect_uri: str | None = None,
|
|
124
|
+
client_id_envvar="NEXTLAYERSDK_CLIENT_ID",
|
|
121
125
|
username_envvar="NEXTLAYERSDK_USERNAME",
|
|
122
126
|
password_envvar="NEXTLAYERSDK_PASSWORD",
|
|
127
|
+
client_secret_envvar="NEXTLAYERSDK_CLIENT_SECRET",
|
|
123
128
|
):
|
|
124
129
|
if config_defaults is None:
|
|
125
130
|
config_defaults = PersistentConfig()
|
|
126
131
|
|
|
132
|
+
self.client_id_envvar = client_id_envvar
|
|
127
133
|
self.username_envvar = username_envvar
|
|
128
134
|
self.password_envvar = password_envvar
|
|
135
|
+
self.client_secret_envvar = client_secret_envvar
|
|
129
136
|
|
|
130
137
|
self.ask_totp = ask_totp
|
|
131
138
|
self.browser_login = browser_login
|
|
@@ -138,12 +145,24 @@ class NlAuth(object):
|
|
|
138
145
|
|
|
139
146
|
new_server_url = (server_url or self.store.config.server_url).rstrip("/") + "/"
|
|
140
147
|
new_realm_name = realm_name or self.store.config.realm_name
|
|
141
|
-
new_client_id =
|
|
148
|
+
new_client_id = (
|
|
149
|
+
client_id
|
|
150
|
+
or os.environ.get(self.client_id_envvar)
|
|
151
|
+
or self.store.config.client_id
|
|
152
|
+
)
|
|
142
153
|
self.username = (
|
|
143
154
|
username
|
|
144
155
|
or os.environ.get(self.username_envvar)
|
|
145
156
|
or self.store.config.username
|
|
146
157
|
)
|
|
158
|
+
self.client_secret = (
|
|
159
|
+
client_secret
|
|
160
|
+
or os.environ.get(self.client_secret_envvar)
|
|
161
|
+
or self.store.config.client_secret
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if self.browser_login and self.client_secret:
|
|
165
|
+
raise ValueError("browser_login and client_secret are mutually exclusive")
|
|
147
166
|
|
|
148
167
|
self.store.config.server_url = new_server_url
|
|
149
168
|
self.store.config.realm_name = new_realm_name
|
|
@@ -151,15 +170,26 @@ class NlAuth(object):
|
|
|
151
170
|
|
|
152
171
|
if self.username:
|
|
153
172
|
self.store.config.username = self.username
|
|
173
|
+
# unlike username/client_id, client_secret is intentionally never written
|
|
174
|
+
# back into self.store.config here - like password, it must not be
|
|
175
|
+
# persisted to the YAML config file in plaintext.
|
|
154
176
|
|
|
155
177
|
# Configure client
|
|
156
178
|
self.keycloak_openid = KeycloakOpenID(
|
|
157
179
|
server_url=new_server_url,
|
|
158
180
|
client_id=new_client_id,
|
|
159
181
|
realm_name=new_realm_name,
|
|
182
|
+
client_secret_key=self.client_secret,
|
|
160
183
|
verify=True,
|
|
161
184
|
)
|
|
162
185
|
|
|
186
|
+
# shared by NlHttpxAuth.async_auth_flow() so that multiple async Auth
|
|
187
|
+
# wrappers around the same NlAuth instance (e.g. one per API client,
|
|
188
|
+
# all backed by one shared NlAuth singleton) serialize their
|
|
189
|
+
# get_access_token() calls instead of racing concurrent
|
|
190
|
+
# do_refresh()/do_login() calls against Keycloak.
|
|
191
|
+
self.async_lock = asyncio.Lock()
|
|
192
|
+
|
|
163
193
|
def token_expired(self) -> bool:
|
|
164
194
|
now = int(time.time())
|
|
165
195
|
exp_at = self.store.config.expires_at
|
|
@@ -190,13 +220,19 @@ class NlAuth(object):
|
|
|
190
220
|
|
|
191
221
|
def update_tokens(self, tokens: dict[str, Any]) -> str:
|
|
192
222
|
log.debug(
|
|
193
|
-
f"update_tokens: expires_in={tokens['expires_in']!r} refresh_expires_in={tokens
|
|
223
|
+
f"update_tokens: expires_in={tokens['expires_in']!r} refresh_expires_in={tokens.get('refresh_expires_in')!r}"
|
|
194
224
|
)
|
|
195
225
|
self.store.config.expires_at = int(time.time()) + int(
|
|
196
226
|
tokens["expires_in"] * 0.75
|
|
197
227
|
)
|
|
198
|
-
|
|
199
|
-
|
|
228
|
+
refresh_expires_in = tokens.get("refresh_expires_in")
|
|
229
|
+
# client_credentials grant responses typically have no refresh_token /
|
|
230
|
+
# refresh_expires_in at all - there is nothing to refresh in that case,
|
|
231
|
+
# so None (rather than some made-up default) is the correct value here
|
|
232
|
+
self.store.config.refresh_expires_at = (
|
|
233
|
+
int(time.time()) + int(refresh_expires_in * 0.75)
|
|
234
|
+
if refresh_expires_in is not None
|
|
235
|
+
else None
|
|
200
236
|
)
|
|
201
237
|
self.store.config.tokens = tokens
|
|
202
238
|
return tokens["access_token"]
|
|
@@ -210,6 +246,12 @@ class NlAuth(object):
|
|
|
210
246
|
self.keycloak_openid,
|
|
211
247
|
redirect_uri=self.browser_login_redirect_uri,
|
|
212
248
|
)
|
|
249
|
+
elif self.client_secret:
|
|
250
|
+
extra = self.store.config.extra_params
|
|
251
|
+
log.debug("make keycloak token-request via client_credentials grant")
|
|
252
|
+
tokens = self.keycloak_openid.token(
|
|
253
|
+
grant_type="client_credentials", **extra
|
|
254
|
+
)
|
|
213
255
|
else:
|
|
214
256
|
password = (
|
|
215
257
|
password
|
|
@@ -262,9 +304,12 @@ class NlAuth(object):
|
|
|
262
304
|
tokens = self.keycloak_openid.refresh_token(
|
|
263
305
|
(self.store.config.tokens or {}).get("refresh_token") or ""
|
|
264
306
|
)
|
|
265
|
-
except
|
|
307
|
+
except KeycloakOperationError as e:
|
|
266
308
|
# e.g., 400: b'{"error":"invalid_grant", "error_description":"Session not active"}'
|
|
267
309
|
# ... when session has been deleted in Keycloak and refresh_token cannot be used anymore
|
|
310
|
+
# note: the keycloak-python client raises KeycloakPostError (not
|
|
311
|
+
# KeycloakGetError) here - KeycloakOperationError is their common
|
|
312
|
+
# base class, so this also covers that case
|
|
268
313
|
sys.stderr.write("token refresh failed: %s\n" % (e,))
|
|
269
314
|
return self.do_login()
|
|
270
315
|
|
|
@@ -279,7 +324,12 @@ class NlAuth(object):
|
|
|
279
324
|
self.store.reload_if_modified()
|
|
280
325
|
except FileNotFoundError:
|
|
281
326
|
pass
|
|
282
|
-
if self.
|
|
327
|
+
if self.client_secret and self.token_expired():
|
|
328
|
+
# client_credentials grant tokens come without a refresh_token
|
|
329
|
+
# (see do_login()), so request a fresh one directly instead of
|
|
330
|
+
# attempting a refresh that would only fail
|
|
331
|
+
access_token = self.do_login()
|
|
332
|
+
elif not self.client_secret and self.refresh_expired():
|
|
283
333
|
access_token = self.do_login()
|
|
284
334
|
elif self.token_expired():
|
|
285
335
|
access_token = self.do_refresh()
|
|
@@ -332,6 +382,10 @@ def parse_commandline_args():
|
|
|
332
382
|
"-i", "--client-id", help=f"keycloak client_id - default: {DEFAULT_CLIENT_ID}"
|
|
333
383
|
)
|
|
334
384
|
parser.add_argument("-u", "--username", help="username")
|
|
385
|
+
# Deliberately no --client-secret CLI flag: command-line arguments are visible
|
|
386
|
+
# to other local users via `ps`/process listings and get persisted in shell
|
|
387
|
+
# history. Use the NEXTLAYERSDK_CLIENT_SECRET environment variable, the config
|
|
388
|
+
# file, or the NlAuth(client_secret=...) constructor param instead.
|
|
335
389
|
parser.add_argument("-a", "--aud", help="set audience - default: not set")
|
|
336
390
|
parser.add_argument(
|
|
337
391
|
"-t",
|
nextlayer/sdk/auth_httpx.py
CHANGED
|
@@ -14,7 +14,6 @@ class NlHttpxAuth(httpx.Auth):
|
|
|
14
14
|
else:
|
|
15
15
|
# pass all arguments to NlAuth constructor and create NlAuth instance internally
|
|
16
16
|
self.nlauth = NlAuth(*args, **kwargs)
|
|
17
|
-
self._lock = asyncio.Lock()
|
|
18
17
|
|
|
19
18
|
def auth_flow(
|
|
20
19
|
self, request: httpx.Request
|
|
@@ -27,9 +26,12 @@ class NlHttpxAuth(httpx.Auth):
|
|
|
27
26
|
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
28
27
|
# NlAuth.get_access_token() does blocking file I/O and, on refresh/login,
|
|
29
28
|
# a blocking HTTP call to Keycloak - offload it so it doesn't stall the
|
|
30
|
-
# event loop. The lock
|
|
31
|
-
#
|
|
32
|
-
|
|
29
|
+
# event loop. The lock lives on the NlAuth instance (not here) so that
|
|
30
|
+
# multiple NlHttpxAuth wrappers sharing one NlAuth (e.g. one per API
|
|
31
|
+
# client, backed by a shared NlAuth singleton) also share the lock -
|
|
32
|
+
# otherwise they could still race concurrent refresh/login calls
|
|
33
|
+
# against Keycloak, which the lock is meant to prevent.
|
|
34
|
+
async with self.nlauth.async_lock:
|
|
33
35
|
access_token = await asyncio.to_thread(self.nlauth.get_access_token)
|
|
34
36
|
request.headers["Authorization"] = "Bearer " + access_token
|
|
35
37
|
yield request
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nextlayer-sdk-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.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
|
|
@@ -51,6 +51,10 @@ variable or directly passed to the `NlAuth` constructor.
|
|
|
51
51
|
Alternatively the password can be suppliend in the `NEXTLAYERSDK_PASSWORD` environment
|
|
52
52
|
variable.
|
|
53
53
|
|
|
54
|
+
For machine-to-machine authentication (no user account), a `client_secret` can be
|
|
55
|
+
supplied instead of `username`/`password` - see
|
|
56
|
+
[Client Credentials Grant](#client-credentials-grant-service-accounts) below.
|
|
57
|
+
|
|
54
58
|
## Usage
|
|
55
59
|
|
|
56
60
|
```python
|
|
@@ -86,6 +90,29 @@ client = httpx.Client(
|
|
|
86
90
|
print(client.get("/users/self").json())
|
|
87
91
|
```
|
|
88
92
|
|
|
93
|
+
### Client Credentials Grant (service accounts)
|
|
94
|
+
|
|
95
|
+
For machine-to-machine authentication without a user account, supply a
|
|
96
|
+
`client_secret` instead of `username`/`password`:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
nlauth = NlAuth(client_id="your_id", client_secret="topsecret")
|
|
100
|
+
access_token = nlauth.get_access_token()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The `client_id`/`client_secret` can also be set via the `NEXTLAYERSDK_CLIENT_ID`/
|
|
104
|
+
`NEXTLAYERSDK_CLIENT_SECRET` environment variables.
|
|
105
|
+
Whenever a `client_secret` is present (non-empty), `NlAuth` auto-detects it and uses
|
|
106
|
+
the OAuth2 `client_credentials` grant instead of the password grant - no `username`
|
|
107
|
+
is needed or used. It is mutually exclusive with `browser_login=True` (passing both
|
|
108
|
+
raises `ValueError`).
|
|
109
|
+
|
|
110
|
+
Note: `client_secret` is deliberately **not** exposed as a `nextlayer-auth` CLI flag -
|
|
111
|
+
command-line arguments are visible to other local users via `ps`/process listings and
|
|
112
|
+
get persisted in shell history. Use the environment variable, the config file, or the
|
|
113
|
+
constructor param instead.
|
|
114
|
+
|
|
115
|
+
|
|
89
116
|
## Usage fron commandline
|
|
90
117
|
|
|
91
118
|
The package also installs a commandline utility `nextlayer-auth` which takes care
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
nextlayer/sdk/__init__.py,sha256=xluvcXl-OARwrx45L8m0tO7b1qpMyGHYi8V_fasDVhM,91
|
|
2
|
+
nextlayer/sdk/__init__.pye,sha256=fIfxryYAp41WbubhUjhitrpwoi6wXFQCZL40RdI4tHA,90
|
|
3
|
+
nextlayer/sdk/auth.py,sha256=nfrjg4CDC7Da_nRNo20qiOE2WJqYFPmUuhgHB9pptq4,16810
|
|
4
|
+
nextlayer/sdk/auth_code_browser_flow.py,sha256=luPScN2WFJtBzUc-6jEGx1zx3cjfgsl1UQweikD3y90,4648
|
|
5
|
+
nextlayer/sdk/auth_httpx.py,sha256=aAd3cABajn7vH9ZGgh_zQVwQos-cpHHFuieu2Xgeasc,1594
|
|
6
|
+
nextlayer/sdk/errors.py,sha256=ZylQSsiFOulLaFwIeBHK0ZWo2Vn6AjML8PoNGIDdx8c,430
|
|
7
|
+
nextlayer/sdk/utils.py,sha256=JPkPuy1bU4jBOf0NunYWMqwrwKmNa2OKNTGPv_UaH6s,583
|
|
8
|
+
nextlayer_sdk_python-1.3.0.dist-info/METADATA,sha256=bqUTZcpoloilOIC0QJc6M21iJs6BBpTzk5jgOcFNpKM,4104
|
|
9
|
+
nextlayer_sdk_python-1.3.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
10
|
+
nextlayer_sdk_python-1.3.0.dist-info/entry_points.txt,sha256=d2f4hShiG0Z_TDG72fCWJdOUZkXPfmPBUIieDXh94d0,58
|
|
11
|
+
nextlayer_sdk_python-1.3.0.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
{nextlayer_sdk_python-1.2.0.dist-info → nextlayer_sdk_python-1.3.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|