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 CHANGED
@@ -1,2 +1,2 @@
1
1
  # do not edit this version line, it will be updated in CI-Pipeline!
2
- __version__ = "v1.2.1"
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 KeycloakGetError
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 = client_id or self.store.config.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['refresh_expires_in']!r}"
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
- self.store.config.refresh_expires_at = int(time.time()) + int(
207
- (tokens["refresh_expires_in"] or 86400) * 0.75
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 KeycloakGetError as e:
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.refresh_expired():
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.2.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=sWOPCd1Jr15QBJpoy9twf0dTxxMeX6ZmRaiOBHmOCCA,91
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=GLqENjPmzq_jVH3xwAtlYcp2NrFq6HTVUf7_cUo08UM,14314
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.2.1.dist-info/METADATA,sha256=4OKFCpgICe_MXLKWFHM4DosAQ7CzKZpmbkyuytapBAc,2906
9
- nextlayer_sdk_python-1.2.1.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
10
- nextlayer_sdk_python-1.2.1.dist-info/entry_points.txt,sha256=d2f4hShiG0Z_TDG72fCWJdOUZkXPfmPBUIieDXh94d0,58
11
- nextlayer_sdk_python-1.2.1.dist-info/RECORD,,
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,,