nextlayer-sdk-python 1.2.1__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 +53 -7
- {nextlayer_sdk_python-1.2.1.dist-info → nextlayer_sdk_python-1.3.0.dist-info}/METADATA +28 -1
- {nextlayer_sdk_python-1.2.1.dist-info → nextlayer_sdk_python-1.3.0.dist-info}/RECORD +6 -6
- {nextlayer_sdk_python-1.2.1.dist-info → nextlayer_sdk_python-1.3.0.dist-info}/WHEEL +0 -0
- {nextlayer_sdk_python-1.2.1.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
|
@@ -12,7 +12,7 @@ from typing import Any
|
|
|
12
12
|
import jwt
|
|
13
13
|
import yaml
|
|
14
14
|
from keycloak import KeycloakOpenID
|
|
15
|
-
from keycloak.exceptions import
|
|
15
|
+
from keycloak.exceptions import KeycloakOperationError
|
|
16
16
|
from pydantic import BaseModel, Field
|
|
17
17
|
|
|
18
18
|
from . import auth_code_browser_flow, errors, utils
|
|
@@ -44,6 +44,7 @@ class PersistentConfig(BaseModel):
|
|
|
44
44
|
client_id: str = DEFAULT_CLIENT_ID
|
|
45
45
|
username: str | None = None
|
|
46
46
|
password: str | None = None
|
|
47
|
+
client_secret: str | None = None
|
|
47
48
|
extra_params: dict[str, Any] = Field(default_factory=dict)
|
|
48
49
|
|
|
49
50
|
# state updated by NlAuth itself:
|
|
@@ -114,19 +115,24 @@ class NlAuth(object):
|
|
|
114
115
|
realm_name: str | None = None,
|
|
115
116
|
client_id: str | None = None,
|
|
116
117
|
username: str | None = None,
|
|
118
|
+
client_secret: str | None = None,
|
|
117
119
|
ask_totp=False,
|
|
118
120
|
browser_login=False,
|
|
119
121
|
# optional customizations of pre-defined defaults:
|
|
120
122
|
config_defaults: PersistentConfig | None = None,
|
|
121
123
|
browser_login_redirect_uri: str | None = None,
|
|
124
|
+
client_id_envvar="NEXTLAYERSDK_CLIENT_ID",
|
|
122
125
|
username_envvar="NEXTLAYERSDK_USERNAME",
|
|
123
126
|
password_envvar="NEXTLAYERSDK_PASSWORD",
|
|
127
|
+
client_secret_envvar="NEXTLAYERSDK_CLIENT_SECRET",
|
|
124
128
|
):
|
|
125
129
|
if config_defaults is None:
|
|
126
130
|
config_defaults = PersistentConfig()
|
|
127
131
|
|
|
132
|
+
self.client_id_envvar = client_id_envvar
|
|
128
133
|
self.username_envvar = username_envvar
|
|
129
134
|
self.password_envvar = password_envvar
|
|
135
|
+
self.client_secret_envvar = client_secret_envvar
|
|
130
136
|
|
|
131
137
|
self.ask_totp = ask_totp
|
|
132
138
|
self.browser_login = browser_login
|
|
@@ -139,12 +145,24 @@ class NlAuth(object):
|
|
|
139
145
|
|
|
140
146
|
new_server_url = (server_url or self.store.config.server_url).rstrip("/") + "/"
|
|
141
147
|
new_realm_name = realm_name or self.store.config.realm_name
|
|
142
|
-
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
|
+
)
|
|
143
153
|
self.username = (
|
|
144
154
|
username
|
|
145
155
|
or os.environ.get(self.username_envvar)
|
|
146
156
|
or self.store.config.username
|
|
147
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")
|
|
148
166
|
|
|
149
167
|
self.store.config.server_url = new_server_url
|
|
150
168
|
self.store.config.realm_name = new_realm_name
|
|
@@ -152,12 +170,16 @@ class NlAuth(object):
|
|
|
152
170
|
|
|
153
171
|
if self.username:
|
|
154
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.
|
|
155
176
|
|
|
156
177
|
# Configure client
|
|
157
178
|
self.keycloak_openid = KeycloakOpenID(
|
|
158
179
|
server_url=new_server_url,
|
|
159
180
|
client_id=new_client_id,
|
|
160
181
|
realm_name=new_realm_name,
|
|
182
|
+
client_secret_key=self.client_secret,
|
|
161
183
|
verify=True,
|
|
162
184
|
)
|
|
163
185
|
|
|
@@ -198,13 +220,19 @@ class NlAuth(object):
|
|
|
198
220
|
|
|
199
221
|
def update_tokens(self, tokens: dict[str, Any]) -> str:
|
|
200
222
|
log.debug(
|
|
201
|
-
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}"
|
|
202
224
|
)
|
|
203
225
|
self.store.config.expires_at = int(time.time()) + int(
|
|
204
226
|
tokens["expires_in"] * 0.75
|
|
205
227
|
)
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
208
236
|
)
|
|
209
237
|
self.store.config.tokens = tokens
|
|
210
238
|
return tokens["access_token"]
|
|
@@ -218,6 +246,12 @@ class NlAuth(object):
|
|
|
218
246
|
self.keycloak_openid,
|
|
219
247
|
redirect_uri=self.browser_login_redirect_uri,
|
|
220
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
|
+
)
|
|
221
255
|
else:
|
|
222
256
|
password = (
|
|
223
257
|
password
|
|
@@ -270,9 +304,12 @@ class NlAuth(object):
|
|
|
270
304
|
tokens = self.keycloak_openid.refresh_token(
|
|
271
305
|
(self.store.config.tokens or {}).get("refresh_token") or ""
|
|
272
306
|
)
|
|
273
|
-
except
|
|
307
|
+
except KeycloakOperationError as e:
|
|
274
308
|
# e.g., 400: b'{"error":"invalid_grant", "error_description":"Session not active"}'
|
|
275
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
|
|
276
313
|
sys.stderr.write("token refresh failed: %s\n" % (e,))
|
|
277
314
|
return self.do_login()
|
|
278
315
|
|
|
@@ -287,7 +324,12 @@ class NlAuth(object):
|
|
|
287
324
|
self.store.reload_if_modified()
|
|
288
325
|
except FileNotFoundError:
|
|
289
326
|
pass
|
|
290
|
-
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():
|
|
291
333
|
access_token = self.do_login()
|
|
292
334
|
elif self.token_expired():
|
|
293
335
|
access_token = self.do_refresh()
|
|
@@ -340,6 +382,10 @@ def parse_commandline_args():
|
|
|
340
382
|
"-i", "--client-id", help=f"keycloak client_id - default: {DEFAULT_CLIENT_ID}"
|
|
341
383
|
)
|
|
342
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.
|
|
343
389
|
parser.add_argument("-a", "--aud", help="set audience - default: not set")
|
|
344
390
|
parser.add_argument(
|
|
345
391
|
"-t",
|
|
@@ -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
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
nextlayer/sdk/__init__.py,sha256=
|
|
1
|
+
nextlayer/sdk/__init__.py,sha256=xluvcXl-OARwrx45L8m0tO7b1qpMyGHYi8V_fasDVhM,91
|
|
2
2
|
nextlayer/sdk/__init__.pye,sha256=fIfxryYAp41WbubhUjhitrpwoi6wXFQCZL40RdI4tHA,90
|
|
3
|
-
nextlayer/sdk/auth.py,sha256=
|
|
3
|
+
nextlayer/sdk/auth.py,sha256=nfrjg4CDC7Da_nRNo20qiOE2WJqYFPmUuhgHB9pptq4,16810
|
|
4
4
|
nextlayer/sdk/auth_code_browser_flow.py,sha256=luPScN2WFJtBzUc-6jEGx1zx3cjfgsl1UQweikD3y90,4648
|
|
5
5
|
nextlayer/sdk/auth_httpx.py,sha256=aAd3cABajn7vH9ZGgh_zQVwQos-cpHHFuieu2Xgeasc,1594
|
|
6
6
|
nextlayer/sdk/errors.py,sha256=ZylQSsiFOulLaFwIeBHK0ZWo2Vn6AjML8PoNGIDdx8c,430
|
|
7
7
|
nextlayer/sdk/utils.py,sha256=JPkPuy1bU4jBOf0NunYWMqwrwKmNa2OKNTGPv_UaH6s,583
|
|
8
|
-
nextlayer_sdk_python-1.
|
|
9
|
-
nextlayer_sdk_python-1.
|
|
10
|
-
nextlayer_sdk_python-1.
|
|
11
|
-
nextlayer_sdk_python-1.
|
|
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,,
|
|
File without changes
|
{nextlayer_sdk_python-1.2.1.dist-info → nextlayer_sdk_python-1.3.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|