stackit-core 0.3.0__tar.gz → 0.4.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.
- {stackit_core-0.3.0 → stackit_core-0.4.0}/PKG-INFO +2 -2
- {stackit_core-0.3.0 → stackit_core-0.4.0}/pyproject.toml +1 -1
- {stackit_core-0.3.0 → stackit_core-0.4.0}/src/stackit/core/auth_methods/key_auth.py +53 -14
- {stackit_core-0.3.0 → stackit_core-0.4.0}/.gitignore +0 -0
- {stackit_core-0.3.0 → stackit_core-0.4.0}/LICENSE.md +0 -0
- {stackit_core-0.3.0 → stackit_core-0.4.0}/NOTICE.txt +0 -0
- {stackit_core-0.3.0 → stackit_core-0.4.0}/README.md +0 -0
- {stackit_core-0.3.0 → stackit_core-0.4.0}/src/stackit/core/__init__.py +0 -0
- {stackit_core-0.3.0 → stackit_core-0.4.0}/src/stackit/core/auth_methods/token_auth.py +0 -0
- {stackit_core-0.3.0 → stackit_core-0.4.0}/src/stackit/core/authorization.py +0 -0
- {stackit_core-0.3.0 → stackit_core-0.4.0}/src/stackit/core/configuration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: stackit-core
|
|
3
|
-
Version:
|
|
3
|
+
Version: v0.4.0
|
|
4
4
|
Summary: Core functionality for the STACKIT SDK for Python
|
|
5
5
|
Project-URL: Homepage, https://github.com/stackitcloud/stackit-sdk-python
|
|
6
6
|
Project-URL: Issues, https://github.com/stackitcloud/stackit-sdk-python/issues
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "stackit-core"
|
|
3
|
-
version = "v0.
|
|
3
|
+
version = "v0.4.0"
|
|
4
4
|
description = "Core functionality for the STACKIT SDK for Python"
|
|
5
5
|
authors = [{ name = "STACKIT Developer Tools", email = "developer-tools@stackit.cloud" }]
|
|
6
6
|
requires-python = ">=3.10,<4.0"
|
|
@@ -54,6 +54,8 @@ class KeyAuth(AuthBase):
|
|
|
54
54
|
executor: ThreadPoolExecutor
|
|
55
55
|
refresh_future: Optional[futures.Future]
|
|
56
56
|
service_account_key: ServiceAccountKey
|
|
57
|
+
_stop: threading.Event
|
|
58
|
+
_refresh_thread: Optional[threading.Thread]
|
|
57
59
|
|
|
58
60
|
def __init__(
|
|
59
61
|
self,
|
|
@@ -66,6 +68,8 @@ class KeyAuth(AuthBase):
|
|
|
66
68
|
self.executor = ThreadPoolExecutor(max_workers=1)
|
|
67
69
|
self.access_token = None
|
|
68
70
|
self.refresh_future = None
|
|
71
|
+
self._stop = threading.Event()
|
|
72
|
+
self._refresh_thread = None
|
|
69
73
|
self.__create_initial_token()
|
|
70
74
|
self.__fetch_token_from_endpoint()
|
|
71
75
|
self.__start_token_refresh_task()
|
|
@@ -79,9 +83,37 @@ class KeyAuth(AuthBase):
|
|
|
79
83
|
if self.__is_token_expired(self.access_token):
|
|
80
84
|
if self.refresh_future is None or self.refresh_future.done():
|
|
81
85
|
self.refresh_future = self.executor.submit(self.__refresh_token)
|
|
86
|
+
refresh_future = self.refresh_future
|
|
87
|
+
else:
|
|
88
|
+
refresh_future = None
|
|
89
|
+
|
|
90
|
+
# Do not hold the lock while waiting. The refresh worker acquires it when
|
|
91
|
+
# updating the access token.
|
|
92
|
+
if refresh_future is not None:
|
|
93
|
+
refresh_future.result()
|
|
94
|
+
|
|
95
|
+
with self.lock:
|
|
82
96
|
r.headers["Authorization"] = f"Bearer {self.access_token}"
|
|
83
97
|
return r
|
|
84
98
|
|
|
99
|
+
def close(self) -> None:
|
|
100
|
+
"""Stop the background token refresh and release its worker.
|
|
101
|
+
|
|
102
|
+
Call this when the instance is no longer needed, for example after
|
|
103
|
+
replacing it during key rotation in a long-running process. Without it
|
|
104
|
+
the refresh thread keeps running and keeps requesting tokens. When it
|
|
105
|
+
returns, no refresh is running and none will start. Safe to call more
|
|
106
|
+
than once.
|
|
107
|
+
"""
|
|
108
|
+
# Set under the lock so the refresh loop cannot submit after shutdown.
|
|
109
|
+
with self.lock:
|
|
110
|
+
self._stop.set()
|
|
111
|
+
atexit.unregister(self.__shutdown)
|
|
112
|
+
thread = self._refresh_thread
|
|
113
|
+
if thread is not None and thread is not threading.current_thread():
|
|
114
|
+
thread.join()
|
|
115
|
+
self.executor.shutdown(wait=True, cancel_futures=True)
|
|
116
|
+
|
|
85
117
|
def __create_initial_token(self) -> None:
|
|
86
118
|
payload = {
|
|
87
119
|
"iss": self.service_account_key.credentials.issuer,
|
|
@@ -108,16 +140,18 @@ class KeyAuth(AuthBase):
|
|
|
108
140
|
response = requests.post(self.token_endpoint, data=body, timeout=self.timeout)
|
|
109
141
|
response.raise_for_status()
|
|
110
142
|
response_json = response.json()
|
|
111
|
-
self.
|
|
112
|
-
|
|
143
|
+
with self.lock:
|
|
144
|
+
self.access_token = response_json["access_token"]
|
|
145
|
+
self.refresh_token = response_json["refresh_token"]
|
|
113
146
|
except requests.RequestException as e:
|
|
114
147
|
raise requests.RequestException("Initial token fetch failed") from e
|
|
115
148
|
|
|
116
149
|
def __start_token_refresh_task(self):
|
|
117
150
|
def token_refresh_task():
|
|
118
|
-
while
|
|
119
|
-
time.sleep(self.TOKEN_EXPIRY_CHECK_INTERVAL.total_seconds())
|
|
151
|
+
while not self._stop.wait(self.TOKEN_EXPIRY_CHECK_INTERVAL.total_seconds()):
|
|
120
152
|
with self.lock:
|
|
153
|
+
if self._stop.is_set():
|
|
154
|
+
return
|
|
121
155
|
if self.refresh_future is not None and self.refresh_future.done():
|
|
122
156
|
self.refresh_future.result()
|
|
123
157
|
if self.__is_token_expired(self.access_token) and (
|
|
@@ -128,43 +162,48 @@ class KeyAuth(AuthBase):
|
|
|
128
162
|
thread = threading.Thread(target=token_refresh_task)
|
|
129
163
|
thread.daemon = True
|
|
130
164
|
thread.start()
|
|
165
|
+
self._refresh_thread = thread
|
|
131
166
|
|
|
132
167
|
def __refresh_token(self):
|
|
133
|
-
|
|
168
|
+
with self.lock:
|
|
169
|
+
refresh_token = self.refresh_token
|
|
170
|
+
|
|
171
|
+
if self.__is_token_expired(refresh_token):
|
|
134
172
|
self.__create_initial_token()
|
|
135
173
|
self.__fetch_token_from_endpoint()
|
|
136
174
|
return
|
|
137
175
|
|
|
138
176
|
body = {
|
|
139
177
|
"grant_type": "refresh_token",
|
|
140
|
-
"refresh_token":
|
|
178
|
+
"refresh_token": refresh_token,
|
|
141
179
|
}
|
|
142
180
|
|
|
143
181
|
last_exception = None
|
|
144
182
|
for _ in range(self.MAX_REFRESH_RETRIES):
|
|
183
|
+
if self._stop.is_set():
|
|
184
|
+
return
|
|
145
185
|
try:
|
|
146
186
|
response = requests.post(self.token_endpoint, data=body, timeout=self.timeout)
|
|
147
187
|
response.raise_for_status()
|
|
148
188
|
response_data = response.json()
|
|
149
189
|
new_token = response_data.get("access_token")
|
|
150
|
-
self.
|
|
190
|
+
with self.lock:
|
|
191
|
+
self.access_token = new_token
|
|
151
192
|
return
|
|
152
193
|
except requests.RequestException as e:
|
|
153
194
|
last_exception = e
|
|
154
195
|
|
|
155
196
|
raise requests.RequestException("Token refresh failed after retries") from last_exception
|
|
156
197
|
|
|
157
|
-
def __is_token_expired(self, token: str) -> bool:
|
|
198
|
+
def __is_token_expired(self, token: Optional[str]) -> bool:
|
|
158
199
|
try:
|
|
159
200
|
decoded_token = jwt.decode(token, options={"verify_signature": False})
|
|
160
201
|
exp = decoded_token.get("exp")
|
|
161
|
-
if exp:
|
|
162
|
-
return
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
except jwt.DecodeError:
|
|
202
|
+
if exp is None:
|
|
203
|
+
return True
|
|
204
|
+
return time.time() > (float(exp) - self.EXPIRATION_LEEWAY.total_seconds())
|
|
205
|
+
except (jwt.InvalidTokenError, TypeError, ValueError):
|
|
166
206
|
return True
|
|
167
|
-
return False
|
|
168
207
|
|
|
169
208
|
def __shutdown(self):
|
|
170
209
|
self.executor.shutdown(wait=False)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|