nextlayer-sdk-python 1.0.2__tar.gz → 1.1.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
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: nextlayer-sdk-python
3
- Version: 1.0.2
3
+ Version: 1.1.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: pydantic (>=2.10.6,<3.0.0)
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 (>=3.0.0,<4.0.0)
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
@@ -3,7 +3,7 @@ name = "nextlayer-sdk-python"
3
3
  packages = [
4
4
  { include = "nextlayer", from="src" }
5
5
  ]
6
- version = "v1.0.2"
6
+ version = "v1.1.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>"]
@@ -18,21 +18,21 @@ nextlayer-auth = "nextlayer.sdk.auth:main"
18
18
 
19
19
  [tool.poetry.dependencies]
20
20
  python = "^3.11"
21
- python-keycloak = "^3.0.0"
21
+ python-keycloak = "^5.8.1"
22
22
  PyYAML = "^6.0"
23
23
  pyjwt = "^2.10.1"
24
- pydantic = "^2.10.6"
24
+ pydantic = "^2.12.5"
25
+ httpx = "^0.28.1"
25
26
 
26
27
  [tool.poetry.group.dev.dependencies]
27
28
  pytest = "*"
28
29
  pytest-cov = "*" # pytest-cov >= 6 requires python 3.9
29
- httpx = "^0.28.1"
30
30
  bandit = "*"
31
31
  black = "*"
32
32
  mypy = "*"
33
33
  isort = "*"
34
34
  types-pyyaml = "*"
35
- pre-commit = "^4.1.0"
35
+ pre-commit = "*"
36
36
 
37
37
  [build-system]
38
38
  requires = ["poetry-core>=1.0.0"]
@@ -1,2 +1,2 @@
1
1
  # do not edit this version line, it will be updated in CI-Pipeline!
2
- __version__ = "v1.0.2"
2
+ __version__ = "v1.1.0"
@@ -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 = _get_current_user_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 = PersistentConfig()
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 = PersistentConfig()
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 = PersistentConfig.model_validate(yaml.safe_load(fil) or {})
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 yaml we only want to store keys that have been explicitly
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 DEFAULT_REALM
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("NEXTLAYERSDK_USERNAME")
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
- password = (
203
- password
204
- or os.environ.get("NEXTLAYERSDK_PASSWORD")
205
- or self.store.config.password
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
- # Interactive:
209
- if (not self.username or not password) and sys.stdin.isatty():
210
- if not self.username:
211
- self.username = getpass.getuser()
212
- if not password:
213
- print(
214
- "# Note: If you want to log in with another username, just press Enter.\n"
215
- "# The password you enter will be used once to obtain new\n"
216
- "# tokens from IAM and will not be stored anywhere. Once the\n"
217
- "# refresh token expires, you will need to enter it again."
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
- extra = self.store.config.extra_params
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["refresh_token"]
263
+ (self.store.config.tokens or {}).get("refresh_token") or ""
245
264
  )
246
265
  except KeycloakGetError as e:
247
- # e.g. 400: b'{"error":"invalid_grant", "error_description":"Session not active"}'
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["access_token"]
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
- return jwt.decode(
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
@@ -7,7 +7,12 @@ from .auth import NlAuth
7
7
 
8
8
  class NlHttpxAuth(httpx.Auth):
9
9
  def __init__(self, *args, **kwargs):
10
- self.nlauth = NlAuth(*args, **kwargs)
10
+ if len(args) == 1 and len(kwargs) == 0 and isinstance(args[0], NlAuth):
11
+ # pass an existing NlAuth instance directly
12
+ self.nlauth = args[0]
13
+ else:
14
+ # pass all arguments to NlAuth constructor and create NlAuth instance internally
15
+ self.nlauth = NlAuth(*args, **kwargs)
11
16
 
12
17
  def auth_flow(
13
18
  self, request: httpx.Request
@@ -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("~")