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.
- nextlayer_sdk_python-1.0.1/PKG-INFO +84 -0
- nextlayer_sdk_python-1.0.1/README.md +64 -0
- nextlayer_sdk_python-1.0.1/pyproject.toml +52 -0
- nextlayer_sdk_python-1.0.1/src/nextlayer/sdk/__init__.py +2 -0
- nextlayer_sdk_python-1.0.1/src/nextlayer/sdk/__init__.pye +2 -0
- nextlayer_sdk_python-1.0.1/src/nextlayer/sdk/auth.py +362 -0
- nextlayer_sdk_python-1.0.1/src/nextlayer/sdk/auth_httpx.py +16 -0
- nextlayer_sdk_python-1.0.1/src/nextlayer/sdk/errors.py +16 -0
|
@@ -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,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
|