nextlayer-sdk-python 1.0.1__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.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.3
2
+ Name: nextlayer-sdk-python
3
+ Version: 1.0.1
4
+ Summary: Client utilities to interact with next layer public APIs
5
+ Author: Wolfgang Powisch
6
+ Author-email: wolfgang.powisch@nextlayer.at
7
+ Requires-Python: >=3.11,<4.0
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: PyYAML (>=6.0,<7.0)
15
+ Requires-Dist: pydantic (>=2.10.6,<3.0.0)
16
+ Requires-Dist: pyjwt (>=2.10.1,<3.0.0)
17
+ Requires-Dist: python-keycloak (>=3.0.0,<4.0.0)
18
+ Description-Content-Type: text/markdown
19
+
20
+ # nextlayer-sdk-python
21
+
22
+ Client utilities to interact with next layer public APIs.
23
+
24
+ ## Configuration
25
+
26
+ The module will use a YAML Config file in , where it also needs to **write** to, to store
27
+ and update the obtained Tokens and their expiry (similar like `kubctl` does with kubeconfig).
28
+
29
+ _Example `~/.nextlayer-sdk/auth.nlcustomers.yml`:_
30
+
31
+ ```yaml
32
+ server_url: https://login.nextlayer.at/auth/
33
+ realm_name: nlcustomers
34
+ client_id: nextlayer-sdk-python
35
+ username: foobar
36
+ password: topsecret
37
+
38
+ extra_params:
39
+ audience: nextlayer-sdk-python
40
+
41
+ ```
42
+
43
+ Except for `username`, all settings are optional, but if no `password` is
44
+ supplied in config, the password will be asked interactively (if STDIN is a TTY) !
45
+
46
+ Alternatively the username can be suppled in the `NEXTLAYERSDK_USERNAME` environment
47
+ variable or directly passed to the `NlAuth` cosntructor.
48
+
49
+ Alternatively the password can be suppliend in the `NEXTLAYERSDK_PASSWORD` environment
50
+ variable.
51
+
52
+ ## Usage
53
+
54
+ ```python
55
+ import requests
56
+ from nextlayer.sdk.auth import NlAuth
57
+
58
+ nlauth = NlAuth()
59
+ access_token = nlauth.get_access_token()
60
+
61
+ # make Request to some API Endpoint
62
+ rsp = requests.get(
63
+ "https://portal.nextlayer.at/apis/v1/users/self",
64
+ headers={"Authorization": "Bearer " + access_token},
65
+ )
66
+ ```
67
+
68
+ You need to call the `.get_access_token()` Method **every time** you make API-Calls,
69
+ because it checks if the Token is still valid and will refresh it if necessary.
70
+
71
+ ### Usage with httpx
72
+
73
+ ```python
74
+ import httpx
75
+
76
+ from nextlayer.sdk.auth_httpx import NlHttpxAuth
77
+
78
+ client = httpx.Client(
79
+ timeout=httpx.Timeout(30.0),
80
+ base_url=f"{base_url}/api/v2",
81
+ auth=NlHttpxAuth(),
82
+ )
83
+ ```
84
+
@@ -0,0 +1,64 @@
1
+ # nextlayer-sdk-python
2
+
3
+ Client utilities to interact with next layer public APIs.
4
+
5
+ ## Configuration
6
+
7
+ The module will use a YAML Config file in , where it also needs to **write** to, to store
8
+ and update the obtained Tokens and their expiry (similar like `kubctl` does with kubeconfig).
9
+
10
+ _Example `~/.nextlayer-sdk/auth.nlcustomers.yml`:_
11
+
12
+ ```yaml
13
+ server_url: https://login.nextlayer.at/auth/
14
+ realm_name: nlcustomers
15
+ client_id: nextlayer-sdk-python
16
+ username: foobar
17
+ password: topsecret
18
+
19
+ extra_params:
20
+ audience: nextlayer-sdk-python
21
+
22
+ ```
23
+
24
+ Except for `username`, all settings are optional, but if no `password` is
25
+ supplied in config, the password will be asked interactively (if STDIN is a TTY) !
26
+
27
+ Alternatively the username can be suppled in the `NEXTLAYERSDK_USERNAME` environment
28
+ variable or directly passed to the `NlAuth` cosntructor.
29
+
30
+ Alternatively the password can be suppliend in the `NEXTLAYERSDK_PASSWORD` environment
31
+ variable.
32
+
33
+ ## Usage
34
+
35
+ ```python
36
+ import requests
37
+ from nextlayer.sdk.auth import NlAuth
38
+
39
+ nlauth = NlAuth()
40
+ access_token = nlauth.get_access_token()
41
+
42
+ # make Request to some API Endpoint
43
+ rsp = requests.get(
44
+ "https://portal.nextlayer.at/apis/v1/users/self",
45
+ headers={"Authorization": "Bearer " + access_token},
46
+ )
47
+ ```
48
+
49
+ You need to call the `.get_access_token()` Method **every time** you make API-Calls,
50
+ because it checks if the Token is still valid and will refresh it if necessary.
51
+
52
+ ### Usage with httpx
53
+
54
+ ```python
55
+ import httpx
56
+
57
+ from nextlayer.sdk.auth_httpx import NlHttpxAuth
58
+
59
+ client = httpx.Client(
60
+ timeout=httpx.Timeout(30.0),
61
+ base_url=f"{base_url}/api/v2",
62
+ auth=NlHttpxAuth(),
63
+ )
64
+ ```
@@ -0,0 +1,52 @@
1
+ [tool.poetry]
2
+ name = "nextlayer-sdk-python"
3
+ packages = [
4
+ { include = "nextlayer", from="src" }
5
+ ]
6
+ version = "v1.0.1"
7
+ description = "Client utilities to interact with next layer public APIs"
8
+ readme = "README.md"
9
+ authors = ["Wolfgang Powisch <wolfgang.powisch@nextlayer.at>"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "Operating System :: POSIX :: Linux",
13
+ "Operating System :: OS Independent",
14
+ ]
15
+
16
+ [tool.poetry.scripts]
17
+ nextlayer-auth = "nextlayer.sdk.auth:main"
18
+
19
+ [tool.poetry.dependencies]
20
+ python = "^3.11"
21
+ python-keycloak = "^3.0.0"
22
+ PyYAML = "^6.0"
23
+ pyjwt = "^2.10.1"
24
+ pydantic = "^2.10.6"
25
+
26
+ [tool.poetry.group.dev.dependencies]
27
+ pytest = "*"
28
+ pytest-cov = "*" # pytest-cov >= 6 requires python 3.9
29
+ httpx = "^0.28.1"
30
+ bandit = "*"
31
+ black = "*"
32
+ mypy = "*"
33
+ isort = "*"
34
+ types-pyyaml = "*"
35
+ pre-commit = "^4.1.0"
36
+
37
+ [build-system]
38
+ requires = ["poetry-core>=1.0.0"]
39
+ build-backend = "poetry.core.masonry.api"
40
+
41
+ [tool.pytest.ini_options]
42
+ addopts = "--cov=nextlayer.sdk --cov-report xml --junitxml=junit.xml"
43
+
44
+ [tool.mypy]
45
+ mypy_path = "src"
46
+ explicit_package_bases = true
47
+ files= [ "src", "tests/src" ]
48
+
49
+ [[tool.mypy.overrides]]
50
+ # TODO: fix to get rid of this
51
+ module = ["keycloak.*"]
52
+ follow_untyped_imports = true
@@ -0,0 +1,2 @@
1
+ # do not edit this version line, it will be updated in CI-Pipeline!
2
+ __version__ = "v1.0.1"
@@ -0,0 +1,2 @@
1
+ # do not edit this version line, it will be updated in CI-Pipeline!
2
+ __version__ = "0.0.0"
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env python3
2
+ import datetime
3
+ import getpass
4
+ import json
5
+ import logging
6
+ import os
7
+ import sys
8
+ import time
9
+ from typing import Any
10
+
11
+ import jwt
12
+ import yaml
13
+ from keycloak import KeycloakOpenID
14
+ from keycloak.exceptions import KeycloakGetError
15
+ from pydantic import BaseModel, Field
16
+
17
+ from . import errors
18
+
19
+ # Retrieve/Refresh Token on demand from next layer IAM
20
+
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ DEFAULT_KEYCLOAK_URL = "https://login.nextlayer.at/auth/"
25
+ DEFAULT_CLIENT_ID = "nextlayer-sdk-python"
26
+ DEFAULT_REALM = "nlcustomers"
27
+
28
+
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
+ def _get_default_config_filename(realm: str) -> str:
43
+ homedir = _get_current_user_homedir()
44
+ default_fn = os.path.join(homedir, ".nextlayer-sdk", f"auth.{realm}.yml")
45
+ if os.path.exists(default_fn):
46
+ return default_fn
47
+ else:
48
+ log.debug(f"no config file found - using {default_fn}")
49
+ os.makedirs(os.path.dirname(default_fn), mode=0o700, exist_ok=True)
50
+ return default_fn
51
+
52
+
53
+ class PersistentConfig(BaseModel):
54
+ server_url: str = DEFAULT_KEYCLOAK_URL
55
+ realm_name: str = DEFAULT_REALM
56
+ client_id: str = DEFAULT_CLIENT_ID
57
+ username: str | None = None
58
+ password: str | None = None
59
+
60
+ expires_at: float | None = None
61
+ refresh_expires_at: float | None = None
62
+ tokens: Any | None = None
63
+
64
+ extra_params: dict[str, Any] = Field(default_factory=dict)
65
+
66
+
67
+ class FilePerRealmAuthStore:
68
+ def __init__(self, filename: str):
69
+ self.config_filename: str = filename
70
+ self.config = PersistentConfig()
71
+ self.config_read_mtime = 0
72
+
73
+ self.read_config()
74
+
75
+ @property
76
+ def mtime(self) -> float:
77
+ """last time the store was modified"""
78
+ return os.stat(self.config_filename).st_mtime
79
+
80
+ def reload_if_modified(self):
81
+ if self.mtime > self.config_read_mtime:
82
+ log.debug(f"config {self.config_filename} changed since we last read it")
83
+ self.read_config()
84
+
85
+ def read_config(self):
86
+ if not os.path.isfile(self.config_filename):
87
+ self.config = PersistentConfig()
88
+ return
89
+ log.debug(f"reading config from {self.config_filename}")
90
+ self.config_read_mtime = self.mtime
91
+ with open(self.config_filename) as fil:
92
+ self.config = PersistentConfig.model_validate(yaml.safe_load(fil) or {})
93
+
94
+ def write_config(self):
95
+ log.debug(f"writing config to {self.config_filename}")
96
+ # in the yaml we only want to store keys that have been explicitly
97
+ # set to some (non-default) value
98
+ data = self.config.model_dump(
99
+ exclude_none=True, exclude_unset=True, exclude_defaults=True
100
+ )
101
+ data["_note"] = (
102
+ "file updated by nextlayer-sdk-python at "
103
+ + datetime.datetime.now().astimezone().isoformat()
104
+ )
105
+ with open(self.config_filename, "w") as fil:
106
+ yaml.safe_dump(data, fil)
107
+
108
+ def set_extra_param(
109
+ self, param: str, value: str | int | float | bool | None = None
110
+ ):
111
+ if value is None:
112
+ # remove it
113
+ self.config.extra_params.pop(param, None)
114
+ else:
115
+ self.config.extra_params[param] = value
116
+ self.write_config()
117
+
118
+
119
+ class NlAuth(object):
120
+ def __init__(
121
+ self,
122
+ config_filename=None,
123
+ server_url=None,
124
+ realm_name=None,
125
+ client_id=None,
126
+ username=None,
127
+ ):
128
+ self.config_filename = config_filename or _get_default_config_filename(
129
+ realm_name or DEFAULT_REALM
130
+ )
131
+ self.store = FilePerRealmAuthStore(self.config_filename)
132
+
133
+ new_server_url = (server_url or self.store.config.server_url).rstrip("/") + "/"
134
+ new_realm_name = realm_name or self.store.config.realm_name
135
+ new_client_id = client_id or self.store.config.client_id
136
+ self.username = (
137
+ username
138
+ or os.environ.get("NEXTLAYERSDK_USERNAME")
139
+ or self.store.config.username
140
+ )
141
+
142
+ self.store.config.server_url = new_server_url
143
+ self.store.config.realm_name = new_realm_name
144
+ self.store.config.client_id = new_client_id
145
+
146
+ if self.username:
147
+ self.store.config.username = self.username
148
+
149
+ # Configure client
150
+ self.keycloak_openid = KeycloakOpenID(
151
+ server_url=new_server_url,
152
+ client_id=new_client_id,
153
+ realm_name=new_realm_name,
154
+ verify=True,
155
+ )
156
+
157
+ def token_expired(self) -> bool:
158
+ now = int(time.time())
159
+ exp_at = self.store.config.expires_at
160
+ is_expired = not exp_at or exp_at <= now
161
+ if is_expired and exp_at:
162
+ log.debug(f"access_token is expired {exp_at!r}")
163
+ return is_expired
164
+
165
+ def refresh_expired(self) -> bool:
166
+ now = int(time.time())
167
+ exp_at = self.store.config.refresh_expires_at
168
+ is_expired = not exp_at or exp_at <= now
169
+ if is_expired and exp_at:
170
+ log.debug(f"refresh_token is expired {exp_at!r}")
171
+ return is_expired
172
+
173
+ def set_extra_param(
174
+ self, param: str, value: str | int | float | bool | None = None
175
+ ):
176
+ self.store.set_extra_param(param, value)
177
+
178
+ def clear_tokens(self):
179
+ log.debug("clearing tokens")
180
+ self.store.config.expires_at = None
181
+ self.store.config.refresh_expires_at = None
182
+ self.store.config.tokens = None
183
+ self.store.write_config()
184
+
185
+ def update_tokens(self, tokens: dict[str, Any]) -> str:
186
+ log.debug(
187
+ f"update_tokens: expires_in={tokens['expires_in']!r} refresh_expires_in={tokens['refresh_expires_in']!r}"
188
+ )
189
+ self.store.config.expires_at = int(time.time()) + int(
190
+ tokens["expires_in"] * 0.75
191
+ )
192
+ self.store.config.refresh_expires_at = int(time.time()) + int(
193
+ (tokens["refresh_expires_in"] or 86400) * 0.75
194
+ )
195
+ self.store.config.tokens = tokens
196
+ return tokens["access_token"]
197
+
198
+ def do_login(self, password: str | None = None) -> str:
199
+ log.debug(
200
+ f"do_login for realm {self.store.config.realm_name} on {self.store.config.server_url}"
201
+ )
202
+ password = (
203
+ password
204
+ or os.environ.get("NEXTLAYERSDK_PASSWORD")
205
+ or self.store.config.password
206
+ )
207
+
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."
218
+ )
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
+
232
+ extra = self.store.config.extra_params
233
+
234
+ log.debug(f"make keycloak token-request for user {self.username}")
235
+ tokens = self.keycloak_openid.token(self.username, password, **extra)
236
+ access_token = self.update_tokens(tokens)
237
+ self.store.write_config()
238
+ return access_token
239
+
240
+ def do_refresh(self) -> str:
241
+ try:
242
+ log.debug("make keycloak token-refresh")
243
+ tokens = self.keycloak_openid.refresh_token(
244
+ self.store.config.tokens["refresh_token"]
245
+ )
246
+ except KeycloakGetError as e:
247
+ # e.g. 400: b'{"error":"invalid_grant", "error_description":"Session not active"}'
248
+ # ... when session has been deleted in Keycloak and refresh_token cannot be used anymore
249
+ sys.stderr.write("token refresh failed: %s\n" % (e,))
250
+ return self.do_login()
251
+
252
+ # sys.stderr.write("obtained new access_token\n")
253
+ access_token = self.update_tokens(tokens)
254
+ self.store.write_config()
255
+ return access_token
256
+
257
+ def get_access_token(self) -> str:
258
+ try:
259
+ try:
260
+ self.store.reload_if_modified()
261
+ except FileNotFoundError:
262
+ pass
263
+ if self.refresh_expired():
264
+ access_token = self.do_login()
265
+ elif self.token_expired():
266
+ access_token = self.do_refresh()
267
+ else:
268
+ access_token = self.store.config.tokens["access_token"]
269
+ return access_token
270
+ except errors.NextlayerSdkError:
271
+ raise
272
+ except Exception as e:
273
+ raise errors.AuthenticationError(e)
274
+
275
+ def access_token_info(self) -> dict[str, Any]:
276
+ access_token = self.get_access_token()
277
+
278
+ log.debug("obtaining certs from keycloak for token-verification")
279
+ return jwt.decode(
280
+ access_token,
281
+ key=jwt.PyJWK(self.keycloak_openid.certs()["keys"][0]),
282
+ options=dict(verify_aud=False),
283
+ algorithms=["HS256", "RS256"],
284
+ )
285
+
286
+
287
+ def parse_commandline_args():
288
+ import argparse
289
+
290
+ parser = argparse.ArgumentParser(
291
+ prog="nextlayer-auth",
292
+ description="Log in via next layer IAM to obtain an access token",
293
+ )
294
+ parser.add_argument(
295
+ "-f",
296
+ "--config",
297
+ help=f"config filename (default: ~/.nextlayer-sdk/auth.{DEFAULT_REALM}.yml)",
298
+ )
299
+ parser.add_argument(
300
+ "-k", "--keycloak-url", help=f"keycloak url - default: {DEFAULT_KEYCLOAK_URL}"
301
+ )
302
+ parser.add_argument(
303
+ "-r", "--realm", help=f"keycloak realm - default: {DEFAULT_REALM}"
304
+ )
305
+ parser.add_argument(
306
+ "-i", "--client-id", help=f"keycloak client_id - default: {DEFAULT_CLIENT_ID}"
307
+ )
308
+ parser.add_argument("-u", "--username", help="username")
309
+ parser.add_argument("-a", "--aud", help="set audience - default: not set")
310
+ parser.add_argument("-v", "--verbose", action="store_true")
311
+ parser.add_argument(
312
+ "command",
313
+ nargs="*",
314
+ default=["login"],
315
+ help="valid commands: login(default), clear, tokeninfo",
316
+ )
317
+ args = parser.parse_args()
318
+ return args
319
+
320
+
321
+ def main() -> int:
322
+ try:
323
+ args = parse_commandline_args()
324
+
325
+ logging.basicConfig()
326
+ if args.verbose:
327
+ log.setLevel(logging.DEBUG)
328
+ log.debug(f"arguments: {args!r}")
329
+
330
+ nlauth = NlAuth(
331
+ config_filename=args.config,
332
+ server_url=args.keycloak_url,
333
+ realm_name=args.realm,
334
+ client_id=args.client_id,
335
+ username=args.username,
336
+ )
337
+
338
+ if args.aud:
339
+ # set Audience Claim
340
+ nlauth.set_extra_param("audience", args.aud)
341
+
342
+ for command in args.command:
343
+ if command == "clear":
344
+ nlauth.clear_tokens()
345
+
346
+ elif command == "login":
347
+ access_token = nlauth.get_access_token()
348
+ sys.stdout.write("Authorization: Bearer " + access_token)
349
+
350
+ elif command == "tokeninfo":
351
+ print(json.dumps(nlauth.access_token_info(), indent=2))
352
+
353
+ else:
354
+ print("unknown command '%s'" % command)
355
+ return 0
356
+ except errors.NextlayerSdkError as e:
357
+ print(e)
358
+ return 1
359
+
360
+
361
+ if __name__ == "__main__":
362
+ sys.exit(main() or 0)
@@ -0,0 +1,16 @@
1
+ from typing import Generator
2
+
3
+ import httpx
4
+
5
+ from .auth import NlAuth
6
+
7
+
8
+ class NlHttpxAuth(httpx.Auth):
9
+ def __init__(self, *args, **kwargs):
10
+ self.nlauth = NlAuth(*args, **kwargs)
11
+
12
+ def auth_flow(
13
+ self, request: httpx.Request
14
+ ) -> Generator[httpx.Request, httpx.Response, None]:
15
+ request.headers["Authorization"] = "Bearer " + self.nlauth.get_access_token()
16
+ yield request
@@ -0,0 +1,16 @@
1
+ class NextlayerSdkError(Exception):
2
+ def __init__(self, message=""):
3
+ self.message = message
4
+ super().__init__(self.message)
5
+
6
+ def __str__(self):
7
+ if self.message:
8
+ return self.__class__.__name__ + ": " + str(self.message)
9
+ else:
10
+ return self.__class__.__name__
11
+
12
+
13
+ class AuthenticationError(NextlayerSdkError):
14
+ """Exception raised for authentication errors."""
15
+
16
+ pass