nextlayer-sdk-python 1.2.0__tar.gz → 1.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nextlayer-sdk-python
3
- Version: 1.2.0
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
@@ -30,6 +30,10 @@ variable or directly passed to the `NlAuth` constructor.
30
30
  Alternatively the password can be suppliend in the `NEXTLAYERSDK_PASSWORD` environment
31
31
  variable.
32
32
 
33
+ For machine-to-machine authentication (no user account), a `client_secret` can be
34
+ supplied instead of `username`/`password` - see
35
+ [Client Credentials Grant](#client-credentials-grant-service-accounts) below.
36
+
33
37
  ## Usage
34
38
 
35
39
  ```python
@@ -65,6 +69,29 @@ client = httpx.Client(
65
69
  print(client.get("/users/self").json())
66
70
  ```
67
71
 
72
+ ### Client Credentials Grant (service accounts)
73
+
74
+ For machine-to-machine authentication without a user account, supply a
75
+ `client_secret` instead of `username`/`password`:
76
+
77
+ ```python
78
+ nlauth = NlAuth(client_id="your_id", client_secret="topsecret")
79
+ access_token = nlauth.get_access_token()
80
+ ```
81
+
82
+ The `client_id`/`client_secret` can also be set via the `NEXTLAYERSDK_CLIENT_ID`/
83
+ `NEXTLAYERSDK_CLIENT_SECRET` environment variables.
84
+ Whenever a `client_secret` is present (non-empty), `NlAuth` auto-detects it and uses
85
+ the OAuth2 `client_credentials` grant instead of the password grant - no `username`
86
+ is needed or used. It is mutually exclusive with `browser_login=True` (passing both
87
+ raises `ValueError`).
88
+
89
+ Note: `client_secret` is deliberately **not** exposed as a `nextlayer-auth` CLI flag -
90
+ command-line arguments are visible to other local users via `ps`/process listings and
91
+ get persisted in shell history. Use the environment variable, the config file, or the
92
+ constructor param instead.
93
+
94
+
68
95
  ## Usage fron commandline
69
96
 
70
97
  The package also installs a commandline utility `nextlayer-auth` which takes care
@@ -3,7 +3,7 @@ name = "nextlayer-sdk-python"
3
3
  packages = [
4
4
  { include = "nextlayer", from="src" }
5
5
  ]
6
- version = "1.2.0"
6
+ version = "1.3.0"
7
7
  description = "Client utilities to interact with next layer public APIs"
8
8
  readme = "README.md"
9
9
  authors = ["Wolfgang Powisch <wolfgang.powisch@nextlayer.at>"]
@@ -1,2 +1,2 @@
1
1
  # do not edit this version line, it will be updated in CI-Pipeline!
2
- __version__ = "v1.2.0"
2
+ __version__ = "v1.3.0"
@@ -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 KeycloakGetError
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 = 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
+ )
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['refresh_expires_in']!r}"
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
- self.store.config.refresh_expires_at = int(time.time()) + int(
199
- (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
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 KeycloakGetError as e:
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.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():
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",
@@ -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 avoids concurrent coroutines all triggering
31
- # redundant refresh/login calls when the token expires under load.
32
- async with self._lock:
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